LoginSignup
3
2

OIDCプロバイダーを実装するとき認証認可画面との連携をどう作るか(ory/hydraの実装例)

Posted at

はじめに

OIDC(OAuth2も同様)プロバイダを実装しようとなった時、フローの種類、State/PKCEといったセキュリティ関連、JWT/IDトークンなどの情報は https://qiita.com/TakahikoKawasaki さんのブログをはじめとして良質な情報がたくさんあるように思います。

一方、認証、認可画面とのリダイレクトの際に状態を管理する上でどのように実装すると良いのかといった情報はほとんどないといって等しいと思います。

私は一人でOIDCプロバイダをゼロから構築する機会があり、https://github.com/ory/hydra を採用しました。実装もそれなりに読み理解したので、認証、認可画面とのリダイレクト部分が具体的にどのようにhydraで実装されているかをはじめとして学びを共有したいと思います。この部分が非常に複雑で私自身も高頻度で混乱するので整理しておきたいというモチベーションもあります。

OIDC関連の基本は把握している前提でのブログとなり、初心者には向かないとは思いますが、プロバイダ実装に興味ある人、hydraを検討している人には参考になるかと思います。

hydraとは何か

OIDCプロバイダを実装しようとなった時、大きく分けて3つ選択肢があると思います。

  1. 自社でゼロから作る
  2. hydra等のOSSを自社のインフラで動かす
  3. 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は他のサービスから依存されることはあっても依存することはありません。

drawio.png

hydraでの実装

認可コードフローにおいて、実際にどのように認証、認可画面とのリダイレクトが実装されているか説明していきたいと思います。

Login EndpointConsent 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_verifierconsent_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_lifespanlogin_rememberlogin_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_rememberconsent_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 です。
errorerror_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_agepromptを指定しても悪意のあるユーザがブラウザで操作してそれらのパラメータを除いたり上書きしてしまう恐れがありますが、auth_time属性をクライアント側で確認することでそれを検知することができます。

promptloginが指定された場合はセッションの期限内でもユーザ認証を必須、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のフローの理解の解像度が上がれば幸いです。

  1. 実際には他のPRJと並行で行う、一時中断するなどあったので開始から2か月以上かかっていますが、それだと工数見積の参考にならないので、1日6時間OIDC構築にのみ注力できると仮定して調整しています

  2. 実際には他のテーブルのカラムを参照してますがこの方法でも実現可能ですし、わかりやすさを優先しています

  3. ここで最新のレコードに限定しているのは興味深いですがバグなのか意図した挙動なのか判断つきません。例えば、現在時刻が12:00として9:00にremember_for=6hにしていたとしても10:00にrembember_for=1hとしていた場合skipされません。途中で異なるgranted_scopeが挟まれる場合も同様です。認可の記憶は常に上書きされるのを正とするのであれば正しい挙動に思えます。細かいところですし問題になるケースはあまりなさそうではあります。

3
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
2