2024年1月5日金曜日

バックエンドが充実していない環境でOpenID Providerを作る

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


昨日こんなことを書きましたので、ちょっと深掘りしてみようと思います。

この辺りは別ポストで詳しくお話しようと思いますが、最後までステートレスにこだわった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 ConnectやOAuthはIDトークンやアクセストークンを取得するためにユーザエージェント(ブラウザ)とRelying Party(クライアント)が認可エンドポイントとトークンエンドポイントとの間のアクセスを行ったり来たりする必要があります。(いわゆるOAuth Dance)

これを実装しようとすると認可エンドポイントへのアクセスとトークンエンドポイントへのアクセスが本当に同一トランザクションの中でのやりとりなのかを確認しないといけませんし、サーバ(OpenID Provider)側としてはトランザクション内での情報の保持をしないといけなくなります。ただHTTPは本来ステートレスな仕組みなので、通常のWebアプリケーションを作る場合と同じ様にサーバ側でのセッション管理をどうするか、しかも認可エンドポイントはユーザエージェントから直接アクセス、トークンエンドポイントはクライアントからのバックエンドアクセスという形なのでcookieで、というわけにも行きません。

通常の実装では何の疑問もなくバックエンドにデータベースを置いてステート管理をサーバ側で実装することになるのですが、グローバルでスケールしている認証サービスの様に極めて高い可用性が必要とされるサービスでその様なインフラを作るのは非常にコストが高い行為といえます。(Entra IDの様に基本無償で提供される様なサービスなら難しい判断になると思います)


そうだ、クライアントにステート情報のハンドリングを任せよう!

そこで考えられたのが全てのやりとりの中で最終的にIDトークンやアクセストークンを発行するのに必要となる情報をもと回れば良いではないか、という考え方だと思われます。(Entra ID以外で見たことはありませんが)

簡単に流れを説明するとこんな感じになっているんだと思います。

認可エンドポイント

  1. ユーザを認証する→最終的にIDトークンに含めるユーザの属性情報を取得する
  2. nonceやaud(クライアントID)など、同じく最終的にIDトークンに含める属性をクエリパラメータから取得する
  3. 1,2で取得した情報を暗号化してJWTにして認可コードとしてクライアントへ戻す
トークンエンドポイント
  1. クライアントからPOSTされた認可コードの検証、復号を行う
  2. 復号した情報をもとに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

  • 認可コードの中身(面倒なので今回は暗号化はしていません)
  • トークンエンドポイントへのPOST 

  • 取得できたIDトークンの中身



本当にこれでいいのか?

まぁ確かにスケーラビリティを考えると非常にエコなので実装としてはありだと思いますが、色々と割り切りをしないといけません。

例えば、

  • 認可コードのサイズが大きくなる
  • アクセストークンはJWT形式(内包型トークン)が前提となり、Introspectionエンドポイントの実装はできない
などが代表的なところです。
特に認可コードのサイズが大きくなる問題は結構深刻で、認可エンドポイントからクライアントへのリダイレクト時のクエリパラメータの長さがものすごいことになりますので、クライアントの実装によっては認可コードが受け取れない、ということが結構あります。ここはMSALを使え!というMicrosoftの理屈なんだと思いますが、既存のアプリケーションとEntra IDを繋ぐ際にはしばしば問題になる可能性があるので、Entra IDの導入を行う際は要注意のポイントの一つです。

まぁ、いずれにしてもID基盤の導入はアプリケーション開発側との認識合わせ・仕様合わせが一番大きなポーションを占めると思うので、この辺りも念頭におきながらEntra IDライフを楽しむべきでしょう。

0 件のコメント: