認証認可アーキテクチャの変遷(5)自作認可サーバーのデータモデルと運用設計
本シリーズは5部構成である。
- 第1部: モノリスから認証基盤へ
- 第2部: API Gatewayとマイクロサービス
- 第3部: クライアント認証の高度化と段階的移行
- 第4部: 認可サーバーの内部構造と信頼性設計
- 第5部(本記事): 自作認可サーバーのデータモデルと運用設計
第4部で「フルスクラッチの創造性を発揮する場所はプロトコルではなく、データモデル・運用性・統合にある」と書いた。第5部はその中身である。RFCはプロトコルのワイヤ上の振る舞いを規定するが、その裏でASが何をどう記憶し、誰に何を許可し、どう管理させるかはほとんど規定していない。ここが自作(あるいは製品の深いカスタマイズ)で実際に設計することになる領域であり、そして製品ごとの思想が最も分かれる領域でもある。
全体像: ASが管理する4つのエンティティ
先にデータモデルの地図を描く。ASの中心にあるのは概ねこの4つだ。
- Client: アプリの登録情報。認証方式、リダイレクトURI、要求可能なスコープ/audience
- Resource Server(audience): トークンの宛先。意外と登録制にされないが、すべき(後述)
- Grant(同意): 「このユーザーが、このクライアントに、このスコープを許した」という事実
- Token(特にリフレッシュトークン): Grantを根拠に発行された、生きている資格
以降、この4つを順に設計していく。通底する原則を先に言ってしまうと、トークンは揮発性のキャッシュ、Grantが永続的な事実であり、参照整合性はGrantを根にして張る——これがこの記事の一番の主張である。
Clientのモデリング
内部クライアントと外部クライアントを分けるか
最初の分岐点であり、答えは「分ける。ただしbooleanではなく信頼レベルとして」だと考えている。
内部(first-party: 自社が開発・運用するアプリ)と外部(third-party: 他社・開発者コミュニティのアプリ)では、ほぼすべての扱いが違う。
| 観点 | 内部クライアント | 外部クライアント |
|---|---|---|
| 同意画面 | スキップが一般的(後述) | 必須 |
| 要求できるスコープ | 広い(管理系も含む) | 審査で許可されたものだけ |
| 登録フロー | 開発チームのセルフサービス | 審査ワークフロー付き |
| レート制限・失効の運用 | 緩め | 厳しめ、緊急停止の対象 |
| クライアント認証 | 組織標準を強制できる | 対応可能な方式に幅が要る |
問題は、OAuth/OIDCの仕様に「first-party」という標準概念が存在しないことだ(IETFでFirst-Party Appsのドラフトが進んでいる段階)。つまりここは各ASが独自に設計する領域で、だからこそ雑にis_internalのようなbooleanで作ってしまいがちになる。しかし運用が始まると「内部だが別事業ドメイン」「買収した子会社のアプリ」「外部だが戦略パートナーで審査を緩和したい」といった中間層が必ず現れる。信頼レベル(trust tier)の列挙型として設計し、「同意スキップ可否」「要求可能スコープの上限」「審査要否」を信頼レベルからの導出ルールにしておくと、中間層の追加がデータの追加で済む。
audの許可 — どのクライアントがどのRS宛てのトークンを取れるか
第1部の補足で書いた通り、アクセストークンのaudにはRSの識別子が入る。すると当然、「クライアントAはどのRS宛てのトークンを要求してよいか」という許可の管理が必要になる。これを設計しないASは、暗黙に「全クライアントが全RS宛てを要求できる」か「audを絞らない全部入りトークンを発行する」かのどちらかに落ちる。どちらも最小権限の原則に反するし、後者は盗まれた1トークンで全APIが叩ける状態を意味する。
モデルとしては Client × ResourceServer × Scope の許可マトリクスになる。実行時の判定はこうだ:
発行するトークンの権限 =
クライアントの許可(登録時に審査された上限)
∩ ユーザーの同意(Grant)
∩ 今回のリクエストでの要求(scope, resourceパラメータ [RFC 8707])
この「3つの積集合」という構造を最初に固定しておくと、個々の仕様(scope、resource indicators、Token Exchange時のaud制約)はすべてこの式の入力として整理できる。なおazpは、audが複数になるトークンで「どのクライアントが取得したか」を刻むための出力側のクレームであり、許可判定の入力はあくまでclient_idである。
スコープの名前空間は誰が所有するか
見落とされがちだが、スコープ名の衝突は実際に起きる。readのような裸のスコープを複数のRSチームが定義し始めると、同意画面の表示もaud判定も曖昧になる。スコープはRSが定義し、RSの名前空間を前置する(payments:read、accounts:write)という規約を、ASのデータモデル(ScopeはResourceServerに属する)として強制してしまうのがよいと思う。規約を文書で運用するより、モデルで表現できない状態を作る方が強い。
Resource Serverの登録と「RS向けポータル」
クライアント登録の議論は多いのに、RSの登録はなぜか語られない。しかしRSも立派な登録対象である。
- audience値の一意性管理: aud識別子の発番・衝突防止はASが台帳を持つしかない
- イントロスペクション資格情報の配布: opaqueトークン構成(第2部)ではRSがASに問い合わせる側になり、RS自身のクレデンシャル管理が要る
- スコープの定義権: 上述の通り、スコープの名前空間はRSに属する。新スコープの追加はRSチームの申請として流れるべきだ
- 鍵・設定変更の通知先: 鍵ローテーションや仕様変更のとき、「トークンを検証している全チーム」に確実に届くリストが要る。これはRS台帳そのものだ
つまりRS向けに必要なのは華やかなポータルではなく、台帳と申請フローである。社内RSが対象なら、Git管理された宣言的設定(Terraform等)+ CIレビューで十分実現でき、第2部で論じたセルフサービス化・ガードレール型統制と同じ設計思想がそのまま適用できる。
開発者ポータルは公開すべきか
クライアント向けの開発者ポータル(セルフサービス登録、ドキュメント、ダッシュボード)を作るかは、外部エコシステムを作る気があるかでほぼ決まる。
- 外部開発者に開くなら必須。登録・審査ワークフロー、シークレットや公開鍵(private_key_jwt用)の登録、リダイレクトURIの管理、サンドボックス環境、利用統計——これらを人手のメール運用でやると、審査の質もスピードも保てない。ポータルはASの付属品ではなく、審査という統制プロセスのUIだと捉えるのがよい
- 社内クライアントだけなら、ポータルよりGitOps。社内開発者にとってWeb UIでのクライアント登録は逆に統制外の変更経路になりやすい。コードレビューを通る宣言的管理の方が、監査証跡と変更管理の両面で優る
- Dynamic Client Registration(RFC 7591/7592)を匿名でインターネットに開くのは慎重に。DCRはプロトコルとしては存在するが、無審査の登録口は悪性クライアントの量産口にもなる。外部向けはポータル(人間の審査を挟む)、machine-to-machineの自動化は認証付きDCR、という使い分けが現実的だと思う
Grant(同意)のモデリングと同意画面の設計
同意画面は何のためにあるか
設計の前に目的をはっきりさせたい。同意画面は認証でも認可判定でもなく、ユーザーへの透明性の装置である。「あなたのデータをこの第三者に渡すが、よいか」を確認する場だ。この目的から逆算すると、出すべき場面と出さなくてよい場面が導ける。
- 内部(first-party)クライアントでは出さないのが一般的で、それでよい。ユーザーは「そのサービス自体」を使っており、サービスが自分のデータにアクセスすることに追加の情報量がない。GoogleがGmailアプリに同意画面を出さないのと同じで、出しても「はい」を押させる儀式にしかならず、同意疲れで本当に重要な同意画面の効果まで薄める
- 外部クライアントでは初回は必ず出す。ここがデータ委任の境界であり、OAuthの原点(第1部のパスワードアンチパターン)が守ろうとしたものそのものだ
- 面白い中間例として、内部クライアントでも「別事業ドメインへのデータ提供」なら出す判断はありうる。組織としては内部でも、ユーザーの期待からすると第三者提供に近い場合だ。信頼レベルを多段にしておく価値はここでも効く
認可済みなら同意画面をスキップしてよいか
質問として非常に良い論点で、答えは「同意済みスコープが要求スコープを包含しているならスキップしてよい。ただし前提条件がある」だ。
Grantを user × client × 承認済みスコープ集合 として永続化しておき、認可リクエスト時に:
要求スコープ ⊆ Grantの承認済みスコープ → 同意画面スキップ
そうでない → 差分スコープだけを提示して追加同意(incremental consent)
とするのが標準的な設計(増分同意)で、主要IdPもこの挙動をする。毎回同意を出す設計は一見誠実に見えるが、実際には同意疲れを生み、ユーザーは読まずに承認するようになる。同意の価値は頻度ではなく、意味のある場面に絞ることで保たれると考えている。
ただしスキップには前提条件がある。
- クライアントのなりすましが防げていること。同意スキップは「このclient_idは過去に承認された」ことを根拠にするので、client_idの詐称とリダイレクトURIの緩い検証(部分一致など)があると、攻撃者が承認済みクライアントを騙ってサイレントにトークンを得る。リダイレクトURIの完全一致検証とPKCEはスキップ運用の前提インフラである
- プロトコルの制御パラメータを尊重すること。
prompt=consentが来たら承認済みでも再提示する(クライアント側が明示的な再確認を求めるケース)、prompt=noneはスキップ可能なときだけ成功させ、同意が必要ならconsent_requiredエラーを返す。この2つはOIDCで規定された挙動なので、独自判断で上書きしない - 同意の取り消し手段とセットであること。同意を永続化してスキップに使うなら、ユーザーが「連携済みアプリ一覧」を見て取り消せるUIが対になる。取り消せない永続同意は、透明性の装置だったはずの同意を形骸化させる
Grantを根にした参照整合性
ここで冒頭の主張に戻る。Grantを独立したエンティティとして持つ最大の理由は、失効の連鎖を張る根になるからだ。ユーザーが連携を取り消したとき、削除すべきはGrantであり、そのGrantを根拠に発行されたリフレッシュトークンは連鎖して失効しなければならない。この参照(RefreshToken → Grant)をモデルに持っていないASは、「連携は解除したのにトークンはまだ生きている」という、ユーザーの期待を裏切る状態を作る。
トークンのモデリング
アクセストークン: 原則「保存しない」
JWT形式なら、アクセストークンはASに保存しないのが基本形だ(第4部の可用性設計とも整合する)。保存したくなる動機は失効と監査だが:
- 失効要件は、まず「短命化 + リフレッシュ時の判定」(第1部)で満たせないか検討する。それでも即時失効が要るなら、全トークンを保存するのではなくjtiのdenylist(失効させたものだけ記録、TTLはトークン寿命と同じ)が軽い
- 監査要件は、トークンの保存ではなく発行イベントのログ(誰に・どのclient・どのaud/scope・どのjti)で満たす。ログは追記のみで改竄困難にしやすく、監査の要求にも本来こちらが合う
opaque形式なら当然ストアが要るが、平文で保存しない。トークンは資格情報なので、DBダンプが漏れたら全ユーザーのセッションが漏れる。ハッシュ化して保存し、突合時にハッシュを引く——パスワードと同じ扱いをする。
リフレッシュトークン: 状態設計の本丸
リフレッシュトークンは長命で強力なので、必ずサーバー側で状態を持ち、設計論点も多い。
- ローテーションと再利用検知(リフレッシュトークンローテーション): 使用のたびに新しいリフレッシュトークンを発行し、旧トークンを無効化する(OAuth 2.0 Security BCPの推奨)。ここで重要なのが**系譜(token family)**のモデリングだ。無効化済みの旧トークンが使われたら、それは盗難のシグナル(正規クライアントと攻撃者のどちらかが古い方を使った)なので、そのファミリー全体を失効させる。この検知は、旧トークンを「削除」ではなく「失効済みとして保持」し、family_idで束ねていないと実装できない。削除ベースで作ると再利用が「未知のトークン」と区別できなくなる
- セッション/デバイスへの紐付け: リフレッシュトークンを
user × clientだけで持つと、「このデバイスだけログアウト」が作れない。発行時のセッション(デバイス)識別子を持たせておくと、デバイス単位の失効・「ログイン中の端末一覧」といった機能の土台になる。後付けでこの軸を足すのはデータ移行を伴うので、最初に入れておく価値が高い - 二重の有効期限: 絶対期限(発行からN日で必ず失効)とアイドル期限(最終使用からM日で失効)の併用が定石。スライディングだけだと、使われ続ける限り永遠に生きるトークンができる
- 保存はハッシュで、last_used_atを記録: 保存の原則はアクセストークンと同じ。加えて最終使用時刻・使用元の記録は、不審な利用の調査とアイドル失効の両方に使う
まとめると、リフレッシュトークンのテーブルには概ね「token_hash, family_id, grant_id, session_id, client_id, user_id, 発行/絶対期限/アイドル期限, 状態(active/rotated/revoked), last_used_at」が並ぶことになる。列の一つひとつが上記のどれかの失効経路・検知経路に対応しており、トークンのモデリングとは失効経路の設計である、と言ってしまってよい気がする。
まとめ: 設計の問いと答えの対応
| 設計の問い | 本記事の答え | 根拠 |
|---|---|---|
| 内部/外部クライアントを分けるか | 分ける。booleanでなく信頼レベルで | 中間層が必ず現れる |
| audの管理は必要か | Client×RS×Scopeの許可マトリクスを持つ | 全部入りトークンの防止、最小権限 |
| RS向けに何を用意するか | ポータルではなく台帳と申請フロー | aud一意性、スコープ名前空間、変更通知 |
| 開発者ポータルは作るか | 外部エコシステムを作るなら必須、社内のみならGitOps | ポータル=審査プロセスのUI |
| 認可済みで同意スキップしてよいか | スコープ包含ならスキップ、差分は増分同意 | 同意疲れの防止。ただし完全一致リダイレクトURI等が前提 |
| 内部クライアントの同意は | 原則スキップ。事業ドメイン越えは例外的に表示 | 同意は透明性の装置という目的から導出 |
| アクセストークンを保存するか | しない。失効はdenylist、監査は発行ログ | 可用性と単純さ |
| リフレッシュトークンの要点は | family/grant/sessionへの参照とハッシュ保存 | モデリング=失効経路の設計 |
第4部の言葉を借りれば、プロトコルの外側にあるこれらの設計こそが自作の本体である。そしてどの問いも、突き詰めると「誰に何を許したかという事実(Grant)を正確に記録し、その事実が消えたときに派生物(トークン)を確実に道連れにできるか」に帰着する。ワイヤ上のプロトコルが委任の「開始」を扱うのに対し、データモデルは委任の「終了」を扱っている——自作するなら、終わらせ方から設計するのが良いと思う。
