はじめに
OIDC(OAuth2も同様)プロバイダを実装しようとなった時、フローの種類、State/PKCEといったセキュリティ関連、JWT/IDトークンなどの情報は https://qiita.com/TakahikoKawasaki さんのブログをはじめとして良質な情報がたくさんあるように思います。
一方、認証、認可画面とのリダイレクトの際に状態を管理する上でどのように実装すると良いのかといった情報はほとんどないといって等しいと思います。
私は一人でOIDCプロバイダをゼロから構築する機会があり、https://github.com/ory/hydra を採用しました。実装もそれなりに読み理解したので、認証、認可画面とのリダイレクト部分が具体的にどのようにhydraで実装されているかをはじめとして学びを共有したいと思います。この部分が非常に複雑で私自身も高頻度で混乱するので整理しておきたいというモチベーションもあります。
OIDC関連の基本は把握している前提でのブログとなり、初心者には向かないとは思いますが、プロバイダ実装に興味ある人、hydraを検討している人には参考になるかと思います。
hydraとは何か
OIDCプロバイダを実装しようとなった時、大きく分けて3つ選択肢があると思います。
- 自社でゼロから作る
- hydra等のOSSを自社のインフラで動かす
- Auth0などのIDaaS (Identity as a Service)を利用する
1はかなりキツそうと思ってましたが、hydraの実装を読むとより「これを自社でやり切るの無理じゃない?」という気持ちになります。なので、ID基盤チームが組織に根付いているようなメガベンチャー以外は1は避けた方がいいと思います。
最近は3を利用する企業が多いように見受けられますが、2も少なくともhydraについては実際に採用してみてかなり良いという感触があります。どのようなものか、採用した場合はどのようなことをどの程度の工数をかけてやらなくてはいけないのかざっくりとではありますがこの章で触れておきます。
hydra はGoで作られたOAuth2/OIDCのプロバイダ機能を提供するOSSです。OAuth2/OIDC機能のみをRESTで提供するマイクロサービスを簡単に立ち上げることができます。
API仕様はhttps://www.ory.sh/docs/hydra/reference/api にあるので、これを眺めてみるとイメージが湧きやすいかもしれません。ポートは二つlistenするようになっており、インターネットに公開するAPIと信頼するノードからのみ叩かれることを想定するAPIの2種類がそれぞれ異なるポートで提供されます。後者の例としてはクライアント作成APIなどがあります。
データベースのmigrationの仕組みは提供されており、IDトークン署名に利用する鍵等も自動で作られるようになっています。また、DB保存時の暗号化やcookieの署名に利用する鍵等もconfファイルに指定すれば良いので、ソースコードは一切書く必要はありません。
「どの程度の工数か」の部分ですが、OIDCの一般的な知識はある特にID基盤等を担当しているのではないサーバサイドエンジニアがhydraの知見がゼロの状態で取り組んで、リリース可能な状態になるまで6週程度でした1。具体的には
- 1~2週目
- ローカルで https://www.ory.sh/docs/hydra/5min-tutorial を動かす
- ^のtutorialが良くできているのでこれを動かして、関連するソースコードの実装を読むと大体挙動が理解できる
- インフラ設計を考える
- API仕様書を書く
- 3~4週目
- terraformでインフラ構築、CI/CD
-
hydra.conf
の設定 - https://hub.docker.com/r/oryd/hydra のdocker imageを利用
- 程度の差はあるかもしれませんが、AWSでは大体下図のような構成になると思われる
- 5週目
- 認証、認可画面の実装(デザイン等は一旦度外視)
- https://github.com/ory/hydra-login-consent-node にあるTypeScriptによる参考実装をほぼ参考にできる
- この部分はユーザのログイン情報などにアクセスできる既存のマイクロサービスで実装することになる
- 6週目
- 動作確認と細かい修正
- https://www.ory.sh/docs/ecosystem/projects にあるドキュメントから関係ありそうなところを読む
- 実装を読んでいるので結果的にはドキュメントにて新しい情報はそこまでなかった
hydraの実装を読む必要があるかという点ですが、必ずしもないかもしれませんし、読まなければ1週縮められると思いますが、長期的に考えると読んでおいた方がいいと個人的には思います。実際、hydraのソースコードの1割未満をざっと読んでいる程度ですが、「〜のパラメータや〜の設定ってどういう意味があるのだろう」となったときに実装をサクッと読んで正確に把握することはできると思います。また、これは気持ちの問題ですが、実装を把握していればより自信をもって構築することができます。
インフラのイメージは以下です。
hydraが必要とするミドルウェアはPostgres等のデータベースのみです。認証の結果のみ把握するので認証情報等は保持しません。また、以下の図を見ればわかるようにhydraは他のサービスから依存されることはあっても依存することはありません。
hydraでの実装
認可コードフローにおいて、実際にどのように認証、認可画面とのリダイレクトが実装されているか説明していきたいと思います。
Login Endpoint
とConsent Endpoint
はHydraのエンドポイントではなく、自前で実装するエンドポイントです。参考実装が https://github.com/ory/hydra-login-consent-node にあるのでこれを参考にすれば困ることはないと思います。
大まかにフローは以下です。認可コードのリクエスト時に指定するredirect_uriに認可コード等をパラメータにつけてリダイレクトする直前までのフローとなります。/admin/~
から始まるパスは既に説明したインターネットには公開されていないAPIです。
1~5と記載している箇所で具体的にどのような実装になっているか説明していきたいと思います。
この記事はhydraの実装を通して、OIDCで認証認可画面との連携にてどのような実装が必要なのかを理解するのが目的なので、わかりやすさを優先し、省略している箇所があります。詳細は https://www.ory.sh/docs/hydra/reference/api 等のdocや実装を確認ください。
flowテーブル
まずは、上記のフローの状態を管理するテーブルflowを先に説明したほうがわかりやすいと思うので軽く触れておきます。詳細のスキーマはこちら を参考にしてください。
カラム | 説明 | 例 |
---|---|---|
login_challenge | a8eb8a15912741efac9cbcfb3ff43f55 | |
login_verifier | acea2d1c82794a37806683f8c7cb5325 | |
login_csrf | hydraがcsrfのために利用する乱数 | 8f35cb1fc0cf46eaa2ea1b36c01d8e48 |
subject | OAuthのsubject | 9005998206264635721 |
request_url | リクエスト時のURLを覚えてredirect等に利用する | https://example.com/oauth2/auth?response\_type=code&client\_id=725e2056-d8c8-4c04-a3fb-e7bb010fd320&redirect\_uri=http://127.0.0.1:4445/callback&scope=openid%20%20offline%20profile%20email&state=1111111111 |
login_skip | hydra側のロジックにてloginをskip可能と判断したか | false |
client_id | 725e2056-d8c8-4c04-a3fb-e7bb010fd320 | |
requested_at | 2023-05-26 11:03:22.000000 | |
login_session_id | ログインのセッションIDでIDトークンのsidに指定される | 291a0a0a-71d3-4839-a718-48506280f618 |
state | フローの状態 | 6 |
login_remember | hydraがユーザが認証済みか否かをcookieを使って覚えるか | true |
login_remember_for | 何秒覚えるか | 60 |
login_error | {} | |
login_authenticated_at | 2023-05-26 11:03:35.000000 | |
login_was_used | ログインフロー済み | true |
consent_challenge_id | 806d3a7054a24a3db3116814359abdbc | |
consent_skip | hydra側のロジックにて認可をskip可能と判断したか | false |
consent_verifier | 7bf78809784c4b71b7a600532c12c051 | |
consent_csrf | hydraがcsrfのために利用する乱数 | aab234167d35476ab23965b970a7fdc3 |
consent_remember | hydraが同一ユーザ同一クライアントの認可を記憶するか | true |
consent_remember_for | 何秒覚えるか | 60 |
consent_handled_at | 2023-05-26 11:03:37.852920 | |
consent_error | {} | |
session_id_token | IDトークンに格納される予定の個人情報等 | {"name": "Jane Doe", "email": "janedoe@example.com", "given_name": "Jane", "family_name": "Doe"} |
consent_was_used | 認可フロー済み | true |
requested_scope | クライアントがリクエストしたscope | ["openid", "offline", "profile", "email"] |
granted_scope | ["openid", "offline", "profile", "email"] | |
login_extend_session_lifespan | 認証スキップしたときにcookieの期限を更新するか | true |
各フロー説明
説明に登場する秒数などは全てconfファイルにてカスタマイズ可能です。説明ではデフォルト値を掲載しています。
1
GET /oauth2/auth
のエンドポイントは上記のフローの中で異なるタイミングで3回叩かれている特殊なエンドポイントです。login_verifier
、consent_verifier
パラメータの有無で1or3or5なのか判断しています。1のタイミングではそのどちらも有りません。
login_challenge
/login_verifier
/login_csrf
/login_session_id
を乱数生成し、flowテーブルに登録します。この時点ではユーザは不明なのでsubject
は空です。requested_at
に現在時刻をrequested_scope
にパラメータで指定されたscopeを入れます。
過去のsessionが現在も有効でありskipできると判断した場合、login_skip
=trueにし、login_session_id
には過去のsession_id、subject
には過去のsessionから取得したsubjectをlogin_authenticated
には過去に認証した時刻をflowテーブルに登録します。
どのような場合にskipできるかは複雑なので後述します。
login_csrf
をcookieに保存します。こちらも後述します。
Login Endpointにlogin_challenge
を付与したurlへの302にてレスポンスします。
2
login_challenge
をもとにhydraからflow情報を取得し、loginをskip可能(flow.login_skip
=true)であればすぐに3のstepへ移動します。以下、skipできない場合を想定します。
hiddenパラメータにlogin_challenge
を埋め込んで、メール、パスワードを入力するフォームをブラウザに返却します。
その後、フォームの入力をPOSTで受け取ると、メール、パスワードをもとに認証を行い、その認証結果をもとにsubject
が取得できるのでそれを元にPUT /admin/oauth2/auth/requests/login/accept
を叩きます。
PUTのパラメータにlogin_extend_session_lifespan
、login_remember
、login_remember_for
も指定可能です。これらはユーザにフォーム画面にて選択させることを検討しても良いでしょう。
PUT /admin/oauth2/auth/requests/login/accept
では、flow.login_was_used
= falseであるか、flow.state
の遷移として許容されている遷移であるかなどをチェックしますが、基本的にはhydraは指定されたパラメータをそれぞれのカラムに、現在時刻をflow.login_authenticated
に保存するだけで、大した仕事はしません。
flow.request_url
に保存してあるurlのクエリパラメータにlogin_verifier
を付与してredirectさせます。2
3
GET /oauth2/auth
の3パターンの中でlogin_verifier
が存在するパターンです。
flow.login_was_used
=trueに更新し、flow.state
も更新します。
flow.requested_at
から30分経過していないか、flow.login_csrf
とcookieを比べて妥当かなどをはじめとしたさまざまなvalidationを行います。
loginをskipせずにflow.login_remember
=trueだった場合、もしくはloginをskipしていてlogin_extend_session_lifespan
=trueだった場合はログインセッションをflow.login_remember_for
後を期限としてcookieに保存します。
consent_challenge
/consent_verifier
/consent_csrf
を乱数生成し、flowテーブルを更新します。
過去のconsentが現在も有効でありskipできると判断した場合、consent_skip
をtrueにします。
どのような場合にskipできるかは複雑なので後述します。
consent_csrf
をcookieに保存します。こちらも後述します。
最後に、認可画面のurlにconsent_challenge
パラメータを付与したurlへの302を返却します。
4
consent_challenge
をもとにhydraからflow情報を取得し、consentをskip可能(flow.consent_skip
=true)であればすぐに5のstepに進みます。以下、skipできない場合を想定します。
hiddenパラメータにconsent_challenge
を埋め込んで、認可のフォームを返却します。この際、「〜に〜の権限を許可しますか」という情報を表示することになります。
「〜に」の部分をわかりやすくユーザに表示できるようにClient作成のAPIを叩くときにClientにヒアリングした値を指定しておくと良いでしょう。
「〜の権限を」の部分ですが、requested_scope
にあるscopeは全て必須で必要としても良いですし、チェックボックス等でユーザが一部のscopeのみ認可できるようにしても良いです。
その後、フォームの入力をPOSTで受け取りPUT /admin/oauth2/auth/requests/consent/accept
を叩きます。
PUTのパラメータにconsent_remember
、consent_remember_for
も指定可能です。これらはユーザにフォーム画面にて選択させることを検討しても良いでしょう。
grant_scope
にはユーザが認可したscopeを指定します。
session
には認可されたscopeにprofile
/email
/address
/phone
等が含まれていた場合にjsonでkey/value形式で指定します。これらは将来IDトークンに含まれることになります。
PUT /admin/oauth2/auth/requests/consent/accept
では、flow.consent_was_used
= falseであるか、flow.state
の遷移として許容されている遷移であるかなどをチェックしますが、基本的にはhydraは指定されたパラメータをそれぞれのカラムに保存するだけで、大した仕事はしません。
flow.request_url
に保存してあるurlのクエリパラメータにconsent_verifier
を付与してredirectさせます。
5
GET /oauth2/auth
の3パターンの中でconsent_verifier
が存在するパターンです。
flow.consent_was_used
=trueに更新し、flow.state
も更新します。
flow.requested_at
から30分経過していないか、flow.consent_csrf
とcookieを比べて妥当かなどをはじめとしたさまざまなvalidationを行います。
生成した認可コードを付与してステップ1の際にクライアントが指定したcallback_uriにredirectします。
この後のフローは省略しますが、認可コードを元にクライアントがtokenエンドポイントを叩くとflow.sid
/flow.session_id_token
を元にIDトークンを返却します。
補足
上記のフローの説明を補足します。
エラー時のredirect
エラーの挙動はOAuth2の仕様で規定されています。
https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1 です。
error
やerror_description
などをクエリに付与してクライアントが指定しているredirect_uriへ遷移します。
redirect_uriを正常に取得する前にエラーが起きる場合があるかもしれません。その場合はhydraではconfファイルにerrorのredirectを受け入れるURLを指定するようになっており、そこでハンドリングすることになります。
スキップ判定
max_age/prompt
これらはhydra独自パラメータではなく、OIDCで規定されているパラメータで、スキップに関連するものです。
https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest に両方とも記載されています。
max_age
は最後に実際に認証(スキップされた場合は含まない)してからmax_age
秒経過していたらログインをskipできないという仕様です。
max_age
を指定すると、IDトークンにauth_time
という属性を含めることが義務付けれれています。max_age
やprompt
を指定しても悪意のあるユーザがブラウザで操作してそれらのパラメータを除いたり上書きしてしまう恐れがありますが、auth_time
属性をクライアント側で確認することでそれを検知することができます。
prompt
にlogin
が指定された場合はセッションの期限内でもユーザ認証を必須、consent
が指定された場合は過去に同一ユーザ同一クライアントに対して同意取得済みでも認可の取得が必須となります。none
は認証認可両方ともスキップできる場合は正常にスキップされますが、そうではない場合はlogin_required
/consent_required
などのエラーで終了します。none
はスキップ可能かどうかをクライアント側が把握する手段として使われるようです。
loginのスキップ実装
ステップ1で説明を省略していた部分を説明します。
まず、prompt
=login
が指定されていた場合はスキップ不可で確定です。
セッションcookieがない場合もスキップ不可で確定です。セッションcookieの保持の条件や期限はステップ3に既に書いた通りです。
max_age
パラメータが指定されていた場合、ログインセッションを管理するテーブルから認証時刻を取得し、認証時刻
+ max_age
< 現在時刻
の場合、スキップ不可で確定します。
スキップ不可でprompt
=none
の場合はlogin_required
エラーとなります。
上記以外の場合、skip可能となります。
なお、当然のことですが、クライアントには依存しないことに注意してください。あくまでユーザの過去の認証の記録とprompt
/max_age
パラメータに依存します。
consentのスキップ実装
ステップ3で説明を省略していた部分を説明します。
まず、prompt
=consent
が指定されていた場合はスキップ不可で確定です。
flowテーブルを参照して、
- stateが認可正常終了後のstate
- subjectが対象subject
- client_idが対象client_id
- consent_skip=false
- consent_remember=true
の条件で最新のレコードを1件取得します3。要するに、同じユーザで同じクライアントに対して明示的にユーザが認可して記憶okだった最新の認可情報を取得しています。
レコードが0件の場合、もしくは consentのリクエスト時刻
+ consent_remember_for
< 現在時刻
の場合はスキップ不可で確定です。
そのレコードのgranted_scopeで今回のrequested_scopeを全てカバーできない場合はスキップ不可で確定です。
prompt
=none
でスキップ不可であった場合、consent_required
エラーとなります。
上記以外の場合、skip可能となります。
CSRF対策
1でlogin_csrf
を発行し、3で検証、3でconsent_csrf
を発行し、5で検証しています。
そもそもなぜCSRF対策をする必要があるのかという点ですが、state
パラメータでのCSRF対策はクライアント側がちゃんとやってくれないと意味がなく、OIDCプロバイダからするとコントロール不能な部分です。それを少しでも軽減するよう認証認可のフローでもCSRF対策をしていると理解しています。docに記載があったのではなく推測です。
CSRF対策の方法が初見で興味深いものでした。
最も典型的で尚且つ一番安全なCSRF対策は、サーバ側でセッションとcsrfトークンをDB等で紐付け、フォームにhiddenパラメータを仕込ませ、POST等でフォームが送信されたときにhiddenパラメータとcsrfトークンを比較するというものです。
その次に有名と思われるのがDouble Submit Cookie
と呼ばれる方法で、フォームが送信されたときにhiddenパラメータとcookieに保存し、サーバ側ではhiddenパラメータをcookieを比較するというものです。これはサーバ側にcsrf関連の情報を保持しなくても良いので楽であるというメリットがある一方、単純な実装だとリスクがあるので信頼できるライブラリを利用するべきです。 https://matope.hatenablog.com/entry/2019/06/05/144435 がわかりやすいです。
今回のCSRF対策はどちらでもなく、両者の中間みたいなもので、サーバ側でセッションとcsrfトークンをflowテーブルで紐付け、cookieにも保存するというものです。フォームのhiddenパラメータが登場しないのですが、そもそも1->3 3->5 はGETでありフォームもないのです。なかなか興味深いです。
また、cookieの名前ですが正確には、ory_hydra_login_csrf
+ client_idのハッシュ値
となっています。これはhttps://github.com/ory/hydra/pull/3059 によると、同一ブラウザで複数のOAuthのフローを走らせている場合を想定しているようです。
まとめ
OIDCでの認証、認可画面との連携部分にフォーカスして説明しました。割と複雑ではないかなと思います。この部分の理解さえなんとなく頭にあればhydraの導入はかなり楽になると思うので参考になればなと思います。hydraを使わない人にもOIDCのフローの理解の解像度が上がれば幸いです。
-
実際には他のPRJと並行で行う、一時中断するなどあったので開始から2か月以上かかっていますが、それだと工数見積の参考にならないので、1日6時間OIDC構築にのみ注力できると仮定して調整しています ↩
-
実際には他のテーブルのカラムを参照してますがこの方法でも実現可能ですし、わかりやすさを優先しています ↩
-
ここで最新のレコードに限定しているのは興味深いですがバグなのか意図した挙動なのか判断つきません。例えば、現在時刻が12:00として9:00にremember_for=6hにしていたとしても10:00にrembember_for=1hとしていた場合skipされません。途中で異なるgranted_scopeが挟まれる場合も同様です。認可の記憶は常に上書きされるのを正とするのであれば正しい挙動に思えます。細かいところですし問題になるケースはあまりなさそうではあります。 ↩