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

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

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

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

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

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

1. 自社でゼロから作る
1. hydra等のOSSを自社のインフラで動かす
1. Auth0などのIDaaS (Identity as a Service)を利用する

1はかなりキツそうと思ってましたが、hydraの実装を読むとより「これを自社でやり切るの無理じゃない？」という気持ちになります。なので、ID基盤チームが組織に根付いているようなメガベンチャー以外は1は避けた方がいいと思います。

最近は3を利用する企業が多いように見受けられますが、2も少なくともhydraについては実際に採用してみてかなり良いという感触があります。どのようなものか、採用した場合はどのようなことをどの程度の工数をかけてやらなくてはいけないのかざっくりとではありますがこの章で触れておきます。

[hydra](https://github.com/ory/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]: 実際には他のPRJと並行で行う、一時中断するなどあったので開始から2か月以上かかっていますが、それだと工数見積の参考にならないので、1日6時間OIDC構築にのみ注力できると仮定して調整しています

* 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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/370237/0cabb157-9fe5-3221-36ee-c7d47af38ba7.png)


# 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や実装を確認ください。

```plantuml
User -> Hydra: GET /oauth2/auth?response_type=code&client_id=${client_id}\n&redirect_uri=${redirect_uri}&scope=${scope}&state=${state}
note right
1
end note
Hydra --> User: redirect to login
User -> "Login Endpoint": GET /oauth/login?login_challenge=${login_challenge}
"Login Endpoint" -> Hydra :GET /admin/oauth2/auth/requests/login?login_challenge=${login_challenge}
Hydra -->  "Login Endpoint": login_challengが存在するか、認証スキップ可能かどうか返却
User <-- "Login Endpoint": メアド、パスワード入力のフォーム画面を表示
note right
2
end note
User ->  "Login Endpoint": POST /oauth/login?login_challenge=${login_challenge}
"Login Endpoint" -> Hydra: PUT /admin/oauth2/auth/requests/login/accept?login_challenge=${login_challenge}
"Login Endpoint" <-- Hydra: login_verifierパラメータを追加したURLを返却
"Login Endpoint" --> User: redirect to hydra
User -> Hydra: GET /oauth2/auth?response_type=...&**login_verifier=${login_verifier}**
note right
3
end note
Hydra --> User: redirect to consent
User -> "Consent Endpoint": GET /oauth/consent?consent_challenge=${consent_challenge}
"Consent Endpoint" -> Hydra :GET /admin/oauth2/auth/requests/consent?consent_challenge=${consent_challenge}
Hydra -->  "Consent Endpoint": consent_challengが存在するか、認可スキップ可能かどうか返却
User <-- "Consent Endpoint": 「〜に〜の権限を与えるのを許可しますか」とフォーム画面を表示
note right
4
end note
User ->  "Consent Endpoint": POST /oauth/consent?consent_challenge=${consent_challenge}
"Consent Endpoint" -> Hydra: PUT /admin/oauth2/auth/requests/consent/accept?consent_challenge=${consent_challenge}
"Consent Endpoint" <-- Hydra: consent_verifierパラメータを追加したURLを返却
"Consent Endpoint" --> User: redirect to hydra
User -> Hydra: GET /oauth2/auth?response_type=...&**consent_verifier=${consent_verifier}**
note right
5
end note
Hydra --> User: redirect to callback endpoint with authrization code
```

## flowテーブル
まずは、上記のフローの状態を管理するテーブルflowを先に説明したほうがわかりやすいと思うので軽く触れておきます。詳細のスキーマは[こちら](https://github.com/ory/hydra/blob/be6e005e8eb245d3844eba133d1f78f9e21b0d0d/flow/flow.go#L81-L238) を参考にしてください。


| カラム |　説明 |例 |
| :- | :- | :- |
| **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** |[フローの状態](https://github.com/ory/hydra/blob/be6e005e8eb245d3844eba133d1f78f9e21b0d0d/flow/flow.go#L26-L69) | 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]

[^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可能となります。


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

## 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のフローの理解の解像度が上がれば幸いです。