2024年1月11日木曜日

OpenID Providerを作る)トークンエンドポイントを作る

こんにちは、富士榮です。

色々と積み残しはありますが、認可エンドポイントの実装が終わったのでトークンエンドポイントを実装してみたいと思います。

その前にこれまでのポストはこちらです。


今回はトークンエンドポイントなので③が対象です。
なお、本日紹介する部分のコードはこちらで公開しています。

ということで始めていきましょう。

トークンエンドポイントの役割と処理

前回、認可エンドポイントの目標はトークンエンドポイントでトークンと交換するための認可コードを取得することである、という説明をしました。今回はトークンエンドポイントなので認可エンドポイントで発行された認可コードを受け取りトークン(IDトークン、アクセストークン、リフレッシュトークン)をクライアントへ渡すことが目標となります。

なお、認可コードフローにおけるトークンエンドポイントの仕様の詳細はこちらに記載があります。
3.1.3.1. Token Requestによるとリクエストをする際は、クライアントの認証をあらかじめ定めた方法で行った上でgrant_typeにauthorization_codeを指定し、取得した認可コードをPOSTする必要がある様です。

こちらが仕様に記載されたリクエストのサンプルです。
  POST /token HTTP/1.1
  Host: server.example.com
  Content-Type: application/x-www-form-urlencoded
  Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW

  grant_type=authorization_code&code=SplxlOBeZQQYbYS6WxSbIA
    &redirect_uri=https%3A%2F%2Fclient.example.org%2Fcb

この例だとAuthorizationヘッダにBasicとあるのでクライアント認証にはBasic認証が使われている様です。このクライアント認証の方式は以前のポストで紹介したメタデータの中に記載されているOpenID Providerがサポートする方式(token_endpoint_auth_methods_supported)から選ぶ必要があります。取りうる値は以下の4種類です。
  • client_secret_basic:クライアントID、シークレットでHTTP Basic認証
  • client_secret_post:クライアントID、シークレットをリクエストボディに含める
  • client_secret_jwt:シークレットを使いJWTを生成し認証に利用する
  • private_key_jwt:あらかじめ公開鍵を登録済みの場合に限られるが秘密鍵で署名したJWTを利用し認証に利用する
シンプルに実装するならclient_secret_basicとclient_secret_postの2つをサポートしておけば十分だと思います。(実際、世の中の実装を見てもこの2つのみをサポートしているOpenId Providerが多いと思います)

今回実装するものでは実際に認証を行いませんので方式は問いませんが、Postmanでテストをする上でわかりやすい(画面ショットを撮る時一枚で済む)のでclient_secret_postを使いたいと思います。

早速コードの全体像を見てみます。前回も述べましたがシンプルに実装するためにバックエンドDBを持たせない構成を取りますので認可コードの中にIDトークンを発行するのに必要な情報が暗号化された状態で埋め込まれている前提で実装しています。
また、スコープ指定もopenidのみとしており、アクセストークンやリフレッシュトークンの発行に関しても考慮対象外としています。

トークンエンドポイントの実装

こちらがコードです。
// トークンエンドポイント
router.post("/token", async (req, res) => {
// 本来なら実装する処理
// - クライアントの認証
// - grant_typeの検証
// - codeの検証(有効期限、発行先クライアント、スコープ)
// - access_tokenの発行
// - id_tokenの発行
const decodedCode = await utils.decryptJWE(req.body.code);
let decodedCodeJSON = JSON.parse(decodedCode);
// 有効期限確認
const date = new Date();
if(decodedCodeJSON.exp < (Math.floor(date.getTime() / 1000))){
res.statusCode = 400;
res.json({
errorMessage: "AuthZ code is expired."
});
} else {
// payload作成
// 単純に期限を延長しているだけ
decodedCodeJSON.exp = Math.floor((date.getTime() + (1000 * 60 * 10)) / 1000);
decodedCodeJSON.iat = Math.floor(date.getTime() / 1000);
const token = await utils.generateJWS(decodedCodeJSON);
console.log(token);
res.json({
access_token: token, // ちなみにEntra IDの場合はaccess_tokenもid_tokenとほぼ同じものが使われるケースもある。
token_type: "Bearer",
expires_in: 3600,
id_token: token
});
}
});

この実装を順番に解説していきたいと思います。

まず、エンドポイントとメソッドの実装です。
router.post("/token", async (req, res) => {
前回も述べた通り、express routerを用いて/oauth2へのリクエストはこのJSファイルへルーティングされる様にしてありますので、/oauth2/tokenに対するPOST要求を拾う様に実装しています。
また、認可コード(JWE)の復号処理が非同期処理となるので後半でawaitを使っています。そのためこの関数自体をasyncにしています。

JWEの復号を行い、有効期限のチェックを行います。
const decodedCode = await utils.decryptJWE(req.body.code);
let decodedCodeJSON = JSON.parse(decodedCode);
// 有効期限確認
const date = new Date();
if(decodedCodeJSON.exp < (Math.floor(date.getTime() / 1000))){
res.statusCode = 400;
res.json({
errorMessage: "AuthZ code is expired."
});

復号処理自体は別のJSファイルに定義した関数を使っています。こちらも前回の暗号化の処理と同じくnode-joseのライブラリを使っています。
有効期限についてはJSONの中のexpに定義されているため、受け取った時刻と比較し、発行から30秒以内であることの確認をし、有効期限切れと判断したらHTTP 400を返却しています。

認可コードに問題がなければ復号済みのJSONの発行時刻を付加、有効期限を更新してデジタル署名を施した上でIDトークンとしてクライアントへ送り返します。
} else {
// payload作成
// 単純に期限を延長しているだけ
decodedCodeJSON.exp = Math.floor((date.getTime() + (1000 * 60 * 10)) / 1000);
decodedCodeJSON.iat = Math.floor(date.getTime() / 1000);
const token = await utils.generateJWS(decodedCodeJSON);
console.log(token);
res.json({
access_token: token, // ちなみにEntra IDの場合はaccess_tokenもid_tokenとほぼ同じものが使われるケースもある。
token_type: "Bearer",
expires_in: 3600,
id_token: token
});
}

なお、こちらも流儀がありますがIDトークンの有効期限はそれほど長く撮る必要はないと考えています。理由はIDトークン自体は認証状態をクライアント(Relying Party)へ伝達することが第一目標となりますので、トークンの使い回しやリプレイ攻撃を避ける意味でもそれほど長い期間使われるものではないからです。今回のコードでは10分間としています。
ちなみに認証サーバとアプリケーションの間の時刻のズレなどを勘案して伝統的に5分くらいの誤差は許容しましょう、というなんとなくな慣習が私の周りにはありましたが、現在は基本的にサーバの時刻同期がされている前提があると思うので、もう少しシビアに有効期限を設定しても良いのかもしれません。

これで無事にIDトークンをクライアントに提供することができたので、トークンエンドポイントの役割は終わりです。実際にリクエストをPostmanで投げてみた結果がこちらです。



この後はクライアント側の処理となるのですが、提供されたトークンが本当に最初の認証リクエストに対して発行されたものなのか、そして発行元が意図したOpenID Providerであり、このIDトークンが自クライアントに対して発行されたものなのか、途中でIDトークンの改竄がなされていないか、を確認する必要があります。

それぞれの確認方法は以下の通りです。
  • 提供されたトークンが本当に最初の認証リクエストに対して発行されたものかどうか
    • 認証リクエストを認可エンドポイントに対して実行する際にnonceというパラメータをつけることを覚えているでしょうか?OpenID Providerはこのnonceの値をIDトークンの中に含めてデジタル署名を行いクライアントへ返却します。クライアントは返却されたIDトークンに含まれるnonceの値と最初の認証リクエストの際に指定したnonceの値が等しいことを確認し、トランザクションの一貫性を検証します。
  • 発行元が意図したOpenID Providerなのか
    • IDトークンの中のissというクレームにトークン発行元であるOpenID Providerの識別子が入りますので、クライアントはissの値を確認することで意図したOpenID Providerから発行されたトークンであることを検証します。
  • トークンが自クライアントに対して発行されたものなのか
    • IDトークンの中のaudというクレームにトークン発行先となるクライアントIDの値がセットされますので、クライアントは受け取ったaudの値が自クライアントのクライアントIDと等しいことで宛先の検証を行います。
  • トークンが途中で改竄されていないこと
    • IDトークン自体はデジタル署名付きのJWTなので、署名検証を行うことで真正性の検証を行います。検証に利用する公開鍵はjwks_uriエンドポイントから取得します。

最後のjwks_uriの実装はこの様な形になっています。
// jwks_uriエンドポイント
router.get('/jwks_uri', async (req, res) => {
const ks = fs.readFileSync(path.resolve(__dirname, "../keys/keystoreSign.json"));
const keyStore = await jose.JWK.asKeyStore(ks.toString());
res.json(keyStore.toJSON())
});

以下のスクリプトでnode-joseの鍵ペア生成の機能を利用してIDトークンに署名するための秘密鍵および検証するための公開鍵の生成を行い、公開鍵をKeyStoreに保存をしています。
// 署名用鍵の生成
const keyStoreForSygn = jose.JWK.createKeyStore();
keyStoreForSygn.generate('RSA', 2048, {alg: 'RS256', use: 'sig' })
.then(result => {
fs.writeFileSync(
'keystoreSign.json',
JSON.stringify(keyStoreForSygn.toJSON(true), null, ' ')
)
});

先のjwks_uriエンドポイントでは保存したKeyStoreの中身をそのまま表示しています。なお、本当は鍵のローテーションなども考慮し、クライアントは受け取ったIDトークンのJWTヘッダから鍵ID(kid)を取得し、そのIDに合致する公開鍵をjwks_uriから取得して署名検証に利用します。
また、公開鍵の情報はそれほど頻繁に更新されるものでもないため、クライアントはjwks_uriから取得した鍵の情報をキャッシュに保存しておき、未知のkidを持つIDトークンが送られてくるまではキャッシュした公開鍵を使って署名検証を行う様に実装するのが定番だと思います。(鍵取得処理のオーバーヘッド回避の意味もあります)


ということでトークンエンドポイントの実装はここまでです。
認可コードフローにおけるコアな処理は前回の認可エンドポイント、今回のトークンエンドポイントでおしまいですが、次回は追加でユーザ情報を取得するためのAPIであるuserInfoエンドポイントの実装についても触れたいと思います。











0 件のコメント: