こんにちは、富士榮です。
昨日こんなことを書きましたので、ちょっと深掘りしてみようと思います。
この辺りは別ポストで詳しくお話しようと思いますが、最後までステートレスにこだわったMicrosoftは認可コードをJWTにして認証済みユーザの属性情報を暗号化して入れることでトークン要求時に認可コードからセッションをLookupしてid_tokenを生成する必要をなくしていたり、VittorioがAuthorのRFC9068/JWT Profile for OAuth 2.0 Access Tokenの様にIntrospectionエンドポイントが無くてもトークンの有効性検証ができる仕組みを実装したり、Hybrid Flowでもないのにフロントで取得できるid_tokenにはほとんど情報を入れず、userInfoやGraph APIへのアクセスをしないとユーザ属性が全く取れなかったり、、、とグローバルスケールの認証基盤を運営する上での苦労が滲み出ていたりします
要するにEntra IDの話なんですが、良いか悪いかは置いておいてOpenID Providerを作るときにバックエンドにDBをなるべく持たずに認可コード、アクセストークン、IDトークンのやりとりを一貫性を持って実行するための工夫の話です。
そもそも何が課題なのか?
これを実装しようとすると認可エンドポイントへのアクセスとトークンエンドポイントへのアクセスが本当に同一トランザクションの中でのやりとりなのかを確認しないといけませんし、サーバ(OpenID Provider)側としてはトランザクション内での情報の保持をしないといけなくなります。ただHTTPは本来ステートレスな仕組みなので、通常のWebアプリケーションを作る場合と同じ様にサーバ側でのセッション管理をどうするか、しかも認可エンドポイントはユーザエージェントから直接アクセス、トークンエンドポイントはクライアントからのバックエンドアクセスという形なのでcookieで、というわけにも行きません。
通常の実装では何の疑問もなくバックエンドにデータベースを置いてステート管理をサーバ側で実装することになるのですが、グローバルでスケールしている認証サービスの様に極めて高い可用性が必要とされるサービスでその様なインフラを作るのは非常にコストが高い行為といえます。(Entra IDの様に基本無償で提供される様なサービスなら難しい判断になると思います)
そうだ、クライアントにステート情報のハンドリングを任せよう!
そこで考えられたのが全てのやりとりの中で最終的にIDトークンやアクセストークンを発行するのに必要となる情報をもと回れば良いではないか、という考え方だと思われます。(Entra ID以外で見たことはありませんが)
簡単に流れを説明するとこんな感じになっているんだと思います。
認可エンドポイント
- ユーザを認証する→最終的にIDトークンに含めるユーザの属性情報を取得する
- nonceやaud(クライアントID)など、同じく最終的にIDトークンに含める属性をクエリパラメータから取得する
- 1,2で取得した情報を暗号化してJWTにして認可コードとしてクライアントへ戻す
- クライアントからPOSTされた認可コードの検証、復号を行う
- 復号した情報をもとにIDトークンを生成してクライアントへ返却する
この辺りをミニマムで実装してみるとこんな感じのコードになると思います。(テスト実装なので細かいところは気にしないでください)
const router = require("express").Router();
const jwt = require("jsonwebtoken");
// jwt署名に使う共有シークレット
const jwtSecretForCode = "this_is_a_secret_for_code_signing";
const jwtSecretForToken = "this_is_a_secret_for_token_signing";
// 認可エンドポイント
router.get("/authorization", async (req, res) => {
// 本来なら実装する処理
// - ユーザの認証
// - response_typeによるフローの振り分け
// - client_idの登録状態の確認
// - redirect_uriとclient_idが示すクライアントとの対応確認
// - scopeの確認
// - 属性送出に関する同意画面の表示
// codeの生成(本来は暗号化しておく)
// 最終的にid_tokenに入れる値をDBに保存する代わりに暗号化してcodeに入れておくことでバックエンドを持たずにすませる
const jwtPayload = {
iss: "myOpenIDProvider",
aud: req.query.client_id,
sub: "authenticatedUserIdentifier",
email: "test@example.jp",
given_name: "taro",
family_name: "test",
nonce: req.query.nonce
};
const jwtOptions = {
algorithm: "HS256", // 面倒なのでHS256にする
expiresIn: "30s" // コードの有効期限なので短め
};
const code = jwt.sign(jwtPayload, jwtSecretForCode, jwtOptions);
// redirect_uriへリダイレクト
res.redirect(req.query.redirect_uri + "?code=" + code + "&state=" + req.query.state);
});
// トークンエンドポイント
router.post("/token", (req, res) => {
// 本来なら実装する処理
// - クライアントの認証
// - grant_typeの検証
// - codeの検証(有効期限、発行先クライアント、スコープ)
// - access_tokenの発行
// - id_tokenの発行
const code = req.body.code;
jwt.verify(code, jwtSecretForCode, (err, decoded)=> {
if(err){
res.json({
err: "counld not verify code"
})
};
if(decoded){
// 本来は検証が終わったらcodeを無効化する
// codeの中身を取り出してid_tokenを作る
const jwtPayload = {
iss: decoded.iss,
aud: decoded.aud,
sub: decoded.sub,
email: decoded.email,
given_name: decoded.given_name,
family_name: decoded.family_name,
nonce: decoded.nonce
};
const jwtOptions = {
algorithm: "HS256", // 面倒なのでHS256
expiresIn: "10m" // id_tokenの有効期限なので少しだけ長め
};
const token = jwt.sign(jwtPayload, jwtSecretForToken, jwtOptions);
res.json({
access_token: token, // ちなみにEntra IDの場合はaccess_tokenもid_tokenとほぼ同じものが使われるケースもある。
token_type: "Bearer",
expires_in: 3600,
id_token: token
});
}
});
});
module.exports = router;
- 認可エンドポイントへのアクセス
http://localhost:3000/oauth2/authorization?client_id=111&redirect_uri=https://localhost:3000/cb&state=hoge
- コールバックへのリダイレクト
https://localhost:3000/cb?code=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJteU9wZW5JRFByb3ZpZGVyIiwiYXVkIjoiMTExIiwic3ViIjoiYXV0aGVudGljYXRlZFVzZXJJZGVudGlmaWVyIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuanAiLCJnaXZlbl9uYW1lIjoidGFybyIsImZhbWlseV9uYW1lIjoidGVzdCIsImlhdCI6MTcwNDQyMDk1MCwiZXhwIjoxNzA0NDIwOTgwfQ.XKi6JLc5IwDNyZG1C7Lrk1UrBiWpOV5EC2NgpqWSXjk&state=hoge
- 認可コードの中身(面倒なので今回は暗号化はしていません)
- 取得できたIDトークンの中身
本当にこれでいいのか?
まぁ確かにスケーラビリティを考えると非常にエコなので実装としてはありだと思いますが、色々と割り切りをしないといけません。
例えば、
- 認可コードのサイズが大きくなる
- アクセストークンはJWT形式(内包型トークン)が前提となり、Introspectionエンドポイントの実装はできない
0 件のコメント:
コメントを投稿