🐷

OAuth2.0 PKCE手続きを Auth0 を利用して TypeScript で実装してみる

2022/07/08に公開約13,000字

概要

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;
};
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ではインメモリ形式で保存されています。

https://logmi.jp/tech/articles/324349

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

ログインするとコメントできます