OAuth2.0 PKCE手続きを Auth0 を利用して TypeScript で実装してみる
概要
PKCE(Proof Key for Code Exchange)という認可コードフローを Auth0 を利用して実際に実装してみることで理解を深めてみようという試みです。
私の記事内では PKCE の詳しい手続きについては触れていませんが、こちらの記事でより詳しく説明しています。実装するにあたり、とても参考にさせていただきました。この場を借りてお礼申し上げます。
PKCEとは
RFC7636 に規定されている認可コード横取り攻撃に対応するための一連の処理の流れになります。
Auth0 のアクセストークン取得までの処理の流れ
大まかに以下の処理の流れを踏みます。
【用語解説】
・code_verifier
A-Z, a-z, 0-9と「-._~」で構成される43~128長のランダムな文字列
・code_challenge
code_verifier を SHA256 という暗号化アルゴリズムでハッシュ化させて BASE64 エンコードさせた文字列
・code_challenge_method
どのような暗号化によって code_verifier から code_challenge を生成したかを示す。今回の例でいうと SHA256 が該当します。
この中でもっとも大事になってくるのは、code_verifier です。
Auth0 は認可コードと共に送られてくる code_verifier を元に code_challenge_method で値を生成してみて、事前に保存しておいた code_challenge と同じ値かどうか確認します。同じ値であれば今まで疎通を取っていた相手だと確認が取れるので、アクセストークンを返します。
文章だけだとなかなかイメージがつかないと思いますので、実際に実装してみようと思います。
注意事項
実装に入る前に注意事項をお伝えします
※ Nuxt3 の環境で実装しています。
いきなりタイトル詐欺みたいになっていますが、実際に暗号化のメソッドだったりは TypeScript を使用しています。クエリを取得したりする PKCE の手続き以外の部分が楽に書けるためこちらを使わせていただきました。
※ 実際の暗号化処理については auth0-spa-jsという Auth0 の SDK で使用されているメソッドを使わせてもらっています。
コードリーディングしつつどのような処理が行われているか細かく説明しながら進めていきます。
事前準備
Auth0 に登録して Application と Management API にリクエストするための API を構築します。
Application
Client ID は後で使います。
API
Auth0 の Management API にアクセスするための API を設定します。
Nuxt
環境変数に先程構築した Application と API を設定します。
.env
NUXT_APP_AUTH0_DOMAIN=YOUR_DOMAIN
NUXT_APP_AUTH0_CLIENT_ID=YOUR_CLIENT_ID
NUXT_APP_AUTH0_AUDIENCE=https://api.pkce-demo.dev
nuxt.config.ts
import { defineNuxtConfig } from "nuxt";
export default defineNuxtConfig({
ssr: false,
runtimeConfig: {
public: {
NUXT_APP_AUTH0_DOMAIN: process.env.NUXT_APP_AUTH0_DOMAIN,
NUXT_APP_AUTH0_CLIENT_ID: process.env.NUXT_APP_AUTH0_CLIENT_ID,
NUXT_APP_AUTH0_AUDIENCE: process.env.NUXT_APP_AUTH0_AUDIENCE,
},
},
});
事前準備は以上です。
実装
では、暗号化のために実際にauth0-spa-jsで使われているメソッドを読み解きながら実装進めていこうと思います。
Auth0のログイン画面を表示するまで
const onSubmit = async () => {
const code_verifier = createRandomString();
const code_challengeBuffer = await sha256(code_verifier);
const code_challenge = bufferToBase64UrlEncoded(code_challengeBuffer);
localStorage.setItem("verifier", code_verifier);
const params = {
response_type: "code", // required
client_id, // 先程設定した Application の ID
audience, // 先程設定した Application の Identifier
redirect_uri: window.location.href, // required
code_challenge,
code_challenge_method: "S256",
scode: "openid profile email read:appointments",
};
window.location.assign(
`https://${domain}/authorize?${createQueryParams(params)}`
);
};
それぞれなにをやっているか見ていきます。
code_verifier
const code_verifier = createRandomString();
console.log(code_verifier);
// 47LcTg2djD8E_og2dGhSXY53Vjl~bZAOgbBRQPjkki-
createRandomString
メソッド名通りランダムな文字列を生成します。
export const createRandomString = (): string => {
const charset =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_~.";
let random = "";
const randomValues = Array.from(
getCrypto().getRandomValues(new Uint8Array(43))
);
randomValues.forEach((v) => (random += charset[v % charset.length]));
return random;
};
それぞれ個別で使われているメソッドを説明します。気になる方は google developer ツールで簡単に試せるので、よかったら試してみてください。
- new Uint8Array()
8ビットの整数配列を作成する。Uint8Array(43)
とあるので、43個の整数配列を作成しています。
new Uint8Array(43)
// Uint8Array(43) [0, 0, 0, 0, 0, 0, 0, 0... 0, 0, buffer: ArrayBuffer(43), byteLength: 43, byteOffset: 0, length: 43, Symbol(Symbol.toStringTag): 'Uint8Array']
-
crypto.getRandomValues
引数に当てた配列に乱数を与える。 Uint8Array と組み合わせているのでこの時点で 43個の乱数配列が生成されています。
crypto.getRandomValues(new Uint8Array(43))
// Uint8Array(43) [198, 197, 209, 5, 21, 101, 32, 79, 115, 224, 142, 140, 58, 247, 238, 191, 253, 123, 95, 107, 16, 209, 17, 9, 196, 88, 85, 69, 92, 119, 74, 82, 171, 119, 48, 1, 181, 80, 98, 168, 26, 234, 53, buffer: ArrayBuffer(43),
getCrypto()
は単純に window.crypto を返しています
export const getCrypto = (): Crypto => {
return window.crypto;
};
-
Array.from()
配列を引数に当てると Shallow Copy された配列を新たに生み出す。Uint8Arrayから生成されたのでArrayBuffer
など不要な値をここでふるい落としています。
Array.from(crypto.getRandomValues(new Uint8Array(43)));
// (43) [119, 163, 128, 49, 10, 52, 143, 0, 217, 141, 62, 109, 28, 20, 227, 240, 66, 248, 111, 180, 98, 212, 242, 69, 83, 228, 167, 153, 188, 180, 6, 70, 134, 131, 122, 188, 6, 234, 176, 59, 207, 46, 96]
この時点で変数 randomValues
には乱数配列が入っています。これを forEach
で回し、ループで回ってくる整数を用意していた charset
の文字数 length
で剰余を取ります。
v(乱数)% charset.length
剰余数値を利用して charset[s(剰余)]
のいづれかの文字にアクセス。random
に注入することによりランダムな文字列が生成されます。
randomValues.forEach((v) => (random += charset[v % charset.length]));
code_challengeBuffer
const code_challengeBuffer = await sha256(code_verifier);
console.log(code_challengeBuffer);
// ArrayBuffer(32)
sha256
固定長(長さが増減しない値)のバッファを作成します。
export const sha256 = async (s: string): Promise<ArrayBuffer> => {
const digest = getCryptoSubtle().digest(
"SHA-256",
new TextEncoder().encode(s)
);
return await digest;
};
- getCryptoSubtle()
crypto.subtleを返却する
export const getCryptoSubtle = (): SubtleCrypto => {
const crypto = getCrypto();
return crypto.subtle;
};
-
crypto.subtle.digest
暗号化処理を行える。crypto.subtle.digest(algorithm, data)
で 第1引数に指定したalgorithm
で第2引数 のdata
をハッシュ化させます。 -
TextEncoder().encode()
文字列をUTF-8にエンコードして、Uint8Arrayを生成します。
new TextEncoder().encode("hogehoge")
// Uint8Array(8) [104, 111, 103, 101, 104, 111, 103, 101, buffer: ArrayBuffer(8), byteLength: 8, byteOffset: 0, length: 8, Symbol(Symbol.toStringTag): 'Uint8Array']
code_challenge
const code_challenge = bufferToBase64UrlEncoded(code_challengeBuffer);
console.log(code_challenge);
// nZBNyTHUTOvhWuEuRFJ2_tK61TNeKJKoNBy6AD9PhDs
bufferToBase64UrlEncoded
バッファを Base64 にエンコードします。
export const bufferToBase64UrlEncoded = (buffer: ArrayBuffer) => {
const uint8array = new Uint8Array(buffer);
return urlEncodeB64(
window.btoa(String.fromCharCode(...Array.from(uint8array)))
);
};
-
String.fromCharCode
引数の文字コード(今回はUint8Array)を UTF-16 に対応した文字列に変換します。 -
btoa
Base64 にエンコードします。
urlEncodeB64
urlに乗せる値のため、url上で特別な意味を持つ「+, /, =」を変換しています。
const urlEncodeB64 = (input: string) => {
const b64Chars: { [index: string]: string } = { "+": "-", "/": "_", "=": "" };
return input.replace(/[+/=]/g, (m: string) => b64Chars[m]);
};
後で使うため、localStorage に code_verifier
を保存
localStorage.setItem("verifier", code_verifier);
簡易的に localStorage に入れていますが、情報漏えいのリスク大なので、この保存方法は避けたほうが良いと思います。
Auth0ではインメモリ形式で保存されています。
Auth0のログイン画面の表示
const params = {
response_type: "code", // required
client_id, // 先程設定した Application の ID
audience, // 先程設定した Application の Identifier
redirect_uri: window.location.href, // required
code_challenge,
code_challenge_method: "S256",
scode: "openid profile email read:appointments",
};
window.location.assign(
`https://${domain}/authorize?${createQueryParams(params)}`
);
設定しているパラメータは Auth0の公式サイト に詳細あるので、ご確認ください。
createQueryParams
クエリパラメータを作成します。
// filter(): undefined をふるい落とす
// map(): key, value を = で結合した新しい配列を作成
// join(): 配列の要素ごとに & で結合
export const createQueryParams = (params: any) => {
return Object.keys(params)
.filter((k) => typeof params[k] !== "undefined")
.map((k) => encodeURIComponent(k) + "=" + encodeURIComponent(params[k]))
.join("&");
};
ボタンイベント設置
<script>
const onSubmit = async () => {
const code_verifier = createRandomString();
const code_challengeBuffer = await sha256(code_verifier);
const code_challenge = bufferToBase64UrlEncoded(code_challengeBuffer);
localStorage.setItem("verifier", code_verifier);
const params = {
response_type: "code", // required
client_id, // 先程設定した Application の ID
audience, // 先程設定した Application の Identifier
redirect_uri: window.location.href, // required
code_challenge,
code_challenge_method: "S256",
scode: "openid profile email read:appointments",
};
window.location.assign(
`https://${domain}/authorize?${createQueryParams(params)}`
);
};
</script>
<template>
<div>
<p>Resister as a member!</p>
<button type="button" @click="onSubmit">register</button>
</div>
</template>
これでボタンをクリックするとログイン画面を表示させることができます。
Auth0 に登録して認可コードを入手
先程のログイン画面の右タブが Sign up
になっているので、こちらで Auth0 に登録します。
すると認可コード付きで設定していたリダイレクト先に戻ってきます。
http://localhost:3000/?code=2sZh0WrefSqcTKYI-rFUi2VnRMocrbJaIT1fgq_VbSAaT
アクセストークンの取得にはこの code
が必要になります。
認可コードを利用してアクセストークンを取得
アクセストークンが取得できるエンドポイントにアクセスしてトークンを取得します。
const requestToken = async () => {
const headers = new Headers({
"Content-Type": "application/x-www-form-urlencoded",
});
const body = new URLSearchParams({
grant_type: "authorization_code",
client_id,
code_verifier,
code,
redirect_uri: window.location.origin,
});
const token = await fetch(`https://${domain}/oauth/token`, {
method: "POST",
body,
headers,
})
.then((res) => res.json())
.catch((e) => console.log(e));
};
requestToken
メソッドを発火させるためのボタンを設置します。
<template>
<div>
<p>Resister as a member!</p>
<button type="button" @click="onSubmit">register</button>
<p v-if="code">
<button @click="requestToken">request token</button>
</p>
</div>
</template>
[request token]ボタンを押下するとアクセストークンが取得できます。
access_token: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImZ5UVl5d1pZVEMxc1....."
expires_in: 86400
token_type: "Bearer"
最後に全体のコード載せておきます。
<script lang="ts" setup>
import {
createRandomString,
sha256,
bufferToBase64UrlEncoded,
createQueryParams,
} from "@/service/utils";
const route = useRoute();
const code = route.query.code as string;
const domain = useRuntimeConfig().NUXT_APP_AUTH0_DOMAIN;
const client_id = useRuntimeConfig().NUXT_APP_AUTH0_CLIENT_ID;
const audience = useRuntimeConfig().NUXT_APP_AUTH0_AUDIENCE;
const code_verifier = localStorage.getItem("verifier");
localStorage.removeItem("verifier");
const requestToken = async () => {
const headers = new Headers({
"Content-Type": "application/x-www-form-urlencoded",
});
const body = new URLSearchParams({
grant_type: "authorization_code",
client_id,
code_verifier,
code,
redirect_uri: window.location.origin,
});
const token = await fetch(`https://${domain}/oauth/token`, {
method: "POST",
body,
headers,
})
.then((res) => res.json())
.catch((e) => console.log(e));
};
const onSubmit = async () => {
const code_verifier = createRandomString();
const code_challengeBuffer = await sha256(code_verifier);
const code_challenge = bufferToBase64UrlEncoded(code_challengeBuffer);
localStorage.setItem("verifier", code_verifier);
const params = {
response_type: "code", // required
client_id, // 先程設定した Application の ID
audience, // 先程設定した Application の Identifier
redirect_uri: window.location.href, // required
code_challenge,
code_challenge_method: "S256",
scode: "openid profile email read:appointments",
};
window.location.assign(
`https://${domain}/authorize?${createQueryParams(params)}`
);
};
</script>
<template>
<div>
<p>Resister as a member!</p>
<button type="button" @click="onSubmit">register</button>
<p v-if="code">
<button @click="requestToken">request token</button>
</p>
</div>
</template>
以上です。
余談
このような処理がauth0-spa-js内でされています。
上記の処理は loginWithRedirect()
で一発です。便利ですね。
Discussion