2024年1月10日水曜日

OpenID Providerを作る)認可エンドポイントを作る

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

引き続き、OpenID Providerを作ろうと思います。
これまでのポストはこちらです。
再掲となりますが、認可コードフローの全体像はこちらです。


今回のポストでは②の認可エンドポイント(Authorize)の部分について実装してみましょう。
なお、今回のシリーズでは最低限の実装を通してOpenID Connectを知ることを目的としていますので、まずは大まかな実装をしておいて細かいところは後から充実させていこうと思います。そのため、先日バックエンドが充実していない環境におけるOpenID Providerの実装について紹介した際の実装をベースにしたいと思います。

なお、本日紹介する部分のコードはこちらで公開しています。

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

認可エンドポイントの役割と処理内容

認可コードフローに限定して話すと認可エンドポイントの目標は「認可コード」を発行することです。クライアント(Relying Party)はこの「認可コード」を後日紹介する「トークンエンドポイント」でIDトークンやアクセストークンと交換しますので、認可エンドポイントが正しく認可コードを発行することはOpenID Connectのフロー全体の安全性を担保する上で非常に重要な第一歩となります。
なお、認可コードフローにおける認可エンドポイントの仕様の詳細はこちらに記載があります。

サポートすべきHTTPメソッドはGET/POST、各種パラメータは以下の通りです。
パラメータ区分意味
scope必須OAuthにおける認可スコープを示すがOpenID Connectの場合はopenidを必ず含める必要がある
response_type必須OAuthにおけるresponse_typeを示す。認可コードフローの場合はcodeを指定する
client_id必須OAuthにおけるクライアントの識別子を示す。OpenID Connectの場合はRelying Partyの識別子となる
redirect_uri必須レスポンスが返されるURIを示す。あらかじめクライアントIDに対して登録されたものと完全一致する必要がある
state推奨リクエストとコールバックの一貫性を確認するために利用する
nonce推奨クライアントのセッションと発行されるIDトークンの紐付けを行うために利用する
display推奨認証および同意のための画面表示の方法を指定する
prompt推奨再認証や同意を求めるかどうかを指定する
max_age任意ユーザが認証されてから再認証を求めるまでの最大許容時間
ui_locales任意UIの言語
id_token_hint任意OpenID Providerが以前発行したIDトークン。過去のセッションに関連した認証を行う場合などに利用する
login_hint任意ログインさせるユーザの識別子を指定する場合に利用する
acr_values任意認証コンテキスト(Authentication Context Class / acr)を指定する場合に利用する

結構色々とパラメータがありますが、大まかな処理の流れはこんな感じになります。(response_typeがcodeの場合)
  1. クライアントの確認を行う(指定されたclient_id、あらかじめ登録されたredirect_uriが合致しているか確認する)
  2. ユーザ認証を行う(通常の実装ではこのエンドポイントを保護しておく)
  3. 指定されたscopeに応じて認証されたユーザの属性情報をユーザDBから取得する
  4. 同意画面の表示
  5. 認可コード(code)とクライアントから指定されたstateをクエリパラメータにつけてredirect_uriにリダイレクトする(UserAgentへHTTP301を返す)

この時、後からトークンエンドポイントで認可コードに紐づくIDトークン(ユーザの情報を含む)を発行することを考えるとバックエンドのDBにクライアントからの要求情報(client_id、redirect_uri、scope)、認証されたユーザの情報と認可コードを保存しておく必要があります。また、同時に認可コードの悪用や再利用を避けるためには認可コード自体の有効期限を極力短くしておく必要があるのと、トークンエンドポイント側で認可コードを利用したら無効化する処理などを実装する必要がありますので、それらの情報(フラグや有効期限など)もDBに保存しておく必要があるはずです。

ただ、処理の流れを知る意味では単純に認可コードを発行さえすればOKですのでクライアント関連の情報やユーザの認証などは飛ばして関連する情報を認可コードの中に入れてしまいましょう。以前のポストでは単純にJWTにしていましたが、Entra IDと同じ様にJWE(JSON Web Encryption)で暗号化した状態にしてあります。

認可エンドポイントの実装

以下の様な実装を行いたいとお思います。
// 認可エンドポイント
router.get("/authorize", async (req, res) => {
// 本来なら実装する処理
// - ユーザの認証
// - response_typeによるフローの振り分け
// - client_idの登録状態の確認
// - redirect_uriとclient_idが示すクライアントとの対応確認
// - scopeの確認
// - 属性送出に関する同意画面の表示

// codeの生成(本来は暗号化しておく)
// 最終的にid_tokenに入れる値をDBに保存する代わりに暗号化してcodeに入れておくことでバックエンドを持たずにすませる
const baseUrl = 'https://' + req.headers.host;

const date = new Date();
const jwePayload = {
iss: baseUrl,
aud: req.query.client_id,
sub: "test",
email: "test@example.jp",
name: "taro test",
given_name: "taro",
family_name: "test",
nonce: req.query.nonce,
exp: Math.floor((date.getTime() + (1000 * 30)) / 1000) // 有効期限は30秒
};
const code = await utils.generateJWE(jwePayload);
console.log(code);
// redirect_uriへリダイレクト
res.redirect(req.query.redirect_uri + "?code=" + code + "&state=" + req.query.state);
});

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

まず、エンドポイントとメソッドの実装です。
router.get("/authorize", async (req, res) => {

次に、本来はバックエンドDBに保存する情報を認可コード自体に含めるため、JSONペイロードを作ります。
const baseUrl = 'https://' + req.headers.host;

const date = new Date();
const jwePayload = {
iss: baseUrl,
aud: req.query.client_id,
sub: "test",
email: "test@example.jp",
name: "taro test",
given_name: "taro",
family_name: "test",
nonce: req.query.nonce,
exp: Math.floor((date.getTime() + (1000 * 30)) / 1000) // 有効期限は30秒
};

後からIDトークンに入れる情報として、
  • iss:Issuerの識別子
  • aud:クライアントID
  • sub、email、name、given_name、family_name:本来はユーザ認証した結果取得する属性情報
  • nonce:リクエストに指定されたnonceの値
  • exp:認可コードの有効期限(短めが良いのでとりあえず30秒)
をペイロードとして定義しています。

このペイロードを暗号化します。JWEを作成するコードは別のJSファイルに定義してあります。
const code = await utils.generateJWE(jwePayload);

こちらがJWEを生成するコードです。node-joseというライブラリを利用しています。また、今回は暗号化と復号を同じサーバで実行できれば良いので対象鍵を利用しています。
// JWEの作成
exports.generateJWE = async function(payload) {
const key = await jose.JWK.asKey({kty:'oct', k: jose.util.base64url.encode(encryptKeyString)});
return jose.JWE.createEncrypt({format:'compact'},key).update(JSON.stringify(payload)).final();
}

実際に作られた認可コードはこの様なものです。
eyJlbmMiOiJBMTI4Q0JDLUhTMjU2IiwiYWxnIjoiQTI1NktXIiwia2lkIjoiOXBheXlGVzBMdEZ0czkyRDE5VVJCSS15bHc5TGh2ZWI1dFJKYkkxU19vdyJ9.XAMPBCzLxSipsZILlvL9r6qdhL0teaYZkpO17RYgVqirv4btHMsdPQ.2kJJ1PExtsZLhI-nPSpLNQ.Kyxyt6TC2CdV81xbq8_roFaZfERZKSg9vnlInTZHnxS8eb9cVAOW_RESgKFWkt6g9vW6CXgO6qE5w-3Oe2J8jV92gVThUwlz5PcQCKgxZ6xkBr5_cRQv5S22GXeEreYVn746qpJGAcEOACyeB5jEFy-RG0ItrGq3FAMaVVBHcHGfAJc9LCk_TaKdOdm3CvVFk67RpNxE9jVsL7oyWSNJqWkrtAsQIYxDi80ZrgGszOc.lYl0xrOMOHNFROtCHCPtng

jwt.ioで見てみても暗号化されていることがわかります。

生成した認可コードとstateをredirect_uriに渡してあげることで認可エンドポイントの役割はおしまいです。
以下の様なクエリがRelying Partyに飛びます。
https://localhost:3000/cb?code=eyJlbmMiOiJBMTI4Q0JDLUhTMjU2IiwiYWxnIjoiQTI1NktXIiwia2lkIjoiOXBheXlGVzBMdEZ0czkyRDE5VVJCSS15bHc5TGh2ZWI1dFJKYkkxU19vdyJ9.XAMPBCzLxSipsZILlvL9r6qdhL0teaYZkpO17RYgVqirv4btHMsdPQ.2kJJ1PExtsZLhI-nPSpLNQ.Kyxyt6TC2CdV81xbq8_roFaZfERZKSg9vnlInTZHnxS8eb9cVAOW_RESgKFWkt6g9vW6CXgO6qE5w-3Oe2J8jV92gVThUwlz5PcQCKgxZ6xkBr5_cRQv5S22GXeEreYVn746qpJGAcEOACyeB5jEFy-RG0ItrGq3FAMaVVBHcHGfAJc9LCk_TaKdOdm3CvVFk67RpNxE9jVsL7oyWSNJqWkrtAsQIYxDi80ZrgGszOc.lYl0xrOMOHNFROtCHCPtng&state=hoge


ということで今回は認可エンドポイントを雑に(?)作りました。次回はトークンエンドポイントです。 

0 件のコメント: