💋

Deno で IDトークンを検証する

2021/06/21に公開

以下を利用して Deno で ID トークンの検証を行います。

コード

検証内容について

検証内容は http://openid-foundation-japan.github.io/openid-connect-core-1_0.ja.html#IDTokenValidation から引用しています。

サンプルコード

本検証のために書いたコードはこちら。
https://github.com/inabajunmr/deno-oidc-rp/blob/main/src/oidc/id-token.ts#L27

前準備

インポート

import {
  decode,
  Header,
  Payload,
  validate,
  verify,
} from "https://deno.land/x/djwt@v2.2/mod.ts";
import { RSA } from "https://deno.land/x/god_crypto@v1.4.8/mod.ts";

ディスカバリ

const res = await fetch("https://example.com/.well-known/openid-configuration");
const discovery = await res.json();

IDトークンのパース

const token = validate(decode(idToken));

暗号化されている IDトークンについては本記事で取り扱いません。

ID Token が 暗号化されているならば, Client が Registration にて指定し OP が ID Token の暗号化に利用した鍵とアルゴリズムを用いて復号する. Registration 時に OP と暗号化が取り決められても ID Token が暗号化されていなかったときは, RP はそれを拒絶するべき (SHOULD).

issuer の検証

(一般的に Discovery を通して取得される) OpenID Provider の Issuer Identifier は iss (issuer) Claim の値と正確に一致しなければならない (MUST).

ディスカバリを使わないパターンの場合、別途取得した issuer の値を利用してください。

if (token.payload.iss !== discovery.issuer) {
  throw new Error("unexpected issuer.");
}

audience と authorized party の検証

Client は aud (audience) Claim が iss (issuer) Claim で示される Issuer にて登録された, 自身の client_id をオーディエンスとして含むことを確認しなければならない (MUST). aud (audience) Claim は複数要素の配列を含んでも良い (MAY). ID Token が Client を有効なオーディエンスとして記載しない, もしくは Client から信用されていない追加のオーディエンスを含むならば, そのID Token は拒絶されなければならない.

ID Token が複数のオーディエンスを含むならば, Client は azp Claim があることを確認すべき (SHOULD).
azp (authorized party) Claim があるならば, Client は Claim の値が自身の client_id であることを確認すべき (SHOULD).

clientId の値は IdP に設定済みの値を利用します。

if (token.payload.aud === undefined) {
  throw Error("ID Token must have aud claim.");
}
// aud が string なら client_id との一致だけを見る
if (typeof token.payload.aud === "string") {
  if (token.payload.aud !== clientId) {
    throw new Error("aud must be client_id.");
  }
} else {
  // aud が配列なら client_id が含まれることを検証
  if (!token.payload.aud?.includes(clientId)) {
    throw new Error("aud must include client_id.");
  }
  // azp クレームが存在することを検証
  if (token.payload.azp === undefined) {
    throw new Error("aud is array so azp is required.");
  }
  // azp クレームが client_id と一致することを検証
  if (token.payload.azp !== clientId) {
    throw new Error("azp must be client_id.");
  }
}

aud クレームが string の場合は clinet_id と単純に比較しています。
配列の場合、配列に client_id が含まれることに加えて azp クレームが存在すること、azp クレームの値が client_id と一致することを検証しています。

署名の検証

(このフローの中で) ID Token を Client と Token Endpoint の間の直接通信により受け取ったならば, トークンの署名確認の代わりに TLS Server の確認を issuer の確認のために利用してもよい (MAY). Client は JWS [JWS] に従い, JWT alg Header Parameter を用いて全ての ID Token の署名を確認しなければならない (MUST). Client は Issuer から提供された鍵を利用しなければならない (MUST).

alg の値はデフォルトの RS256 もしくは Registration にて Client により id_token_signed_response_alg パラメータとして送られたアルゴリズムであるべき (SHOULD).

今回は JWS として検証を行います。
id_token_signed_response_alg の指定がある場合、IDトークンの alg ヘッダーと比較します。

if (token.header.alg !== idTokenSignedResponseAlg) {
  throw new Error("alg must be " + idTokenSignedResponseAlg);
}

指定がない場合、RS256 であることを検証します。

if (token.header.alg !== "RS256") {
  throw new Error("alg must be RS256");
}

次に署名を検証します。

alg ヘッダーの確認

if (["RS256", "RS512", "PS256", "PS512"].includes(header.alg)) {
  // 非対称鍵の場合
} else if (["HS256", "HS512"].includes(header.alg)) {
  // 対称鍵の場合
} else {
  throw new Error(`${header.alg} is not supported.`);
}

非対称鍵の場合

まずディスカバリで取得した jwks_uri から公開鍵を取得します。

const jwks = await fetch(discovery.jwks_uri).then((response) => {
  return response.json();
});

取得した JWKs から IDトークンの kid ヘッダに指定されている鍵にマッチする鍵を取得します。

const jwk = jwks.keys.find(
      function (x: string) {
        return Object(x).kid == token.header.kid;
      },

署名を検証します。

await verify(jwt, RSA.importKey(jwk).pem(), header.alg);

対称鍵の場合

JWT alg Header Parameter が HS256, HS384 および HS512 のような MAC ベースのアルゴリズムを利用するならば, aud (audience) Claim に含まれる client_id に対応する client_secret の UTF-8 表現バイト列が署名の確認に用いられる. MAC ベースのアルゴリズムについて, aud が複数の値を持つとき, もしくは aud の値と異なる azp の値があるときの振る舞いは規定されない.

署名を検証します。
clientSecret の値は IdP に設定済みの値を利用します。

await verify(jwt, clientSecret, header.alg);

exp クレームの検証

現在時刻は exp Claim の時刻表現より前でなければならない (MUST).

if (token.payload.exp === undefined || token.payload.exp < Date.now() / 1000) {
  throw new Error("expired id token.");
}

iat クレームの検証

iat Claim は現在時刻からはるか昔に発行されたトークンを拒絶するために利用でき, 攻撃を防ぐために nonce が保存される必要がある期間を制限する. 許容できる範囲は Client の仕様である.

なんとなく1時間までは許容していますが、ポリシーに合わせて変更してください。

if (token.payload.iat !== undefined && token.payload.iat < Date.now() / 1000 - 3600) {
  throw new Error("iat is too old.");
}

nonce の検証

nonce の値が Authentication Request にて送られたならば, nonce Claim が存在し, その値が Authentication Request にて送られたものと一致することを確認するためにチェックされなければならない (MUST). Client は nonce の値を リプレイアタックのためにチェックすべき (SHOULD). リプレイアタックを検知する正確な方法は Client の仕様である.

nonce の値は認可リクエストで指定した nonce の値を利用します。

if (token.payload.nonce !== nonce) {
  throw new Error("nonce unmatched.");
}

acr の検証

acr Claim が 要求されたならば, Client は主張された Claim の値が適切かどうかをチェックすべきである (SHOULD). acr Claim の値と意味はこの仕様の対象外である.

クライアントごとに必要に応じてよしなに検証してください。

auth_time の検証

auth_time Claim が要求されたならば, この Claim のための特定のリクエストもしくは max_age パラメータを用いて Client は auth_time Claim の値をチェックし, もし最新のユーザー認証からあまりに長い時間が経過したと判定されたときは再認証を要求すべきである (SHOULD).

なんとなく1時間までは許容していますが、ポリシーに合わせて変更してください。

if (authTime !== undefined && typeof (authTime) === "number" && authTime < Date.now() / 1000 - 3600) {
  throw new Error("auth_time is too old.");
}

参考資料

GitHubで編集を提案

Discussion