logoChibiham
cover
🗄️

認証認可アーキテクチャの変遷(5)自作認可サーバーのデータモデルと運用設計

本シリーズは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:readaccounts: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もこの挙動をする。毎回同意を出す設計は一見誠実に見えるが、実際には同意疲れを生み、ユーザーは読まずに承認するようになる。同意の価値は頻度ではなく、意味のある場面に絞ることで保たれると考えている。

ただしスキップには前提条件がある。

  1. クライアントのなりすましが防げていること。同意スキップは「このclient_idは過去に承認された」ことを根拠にするので、client_idの詐称とリダイレクトURIの緩い検証(部分一致など)があると、攻撃者が承認済みクライアントを騙ってサイレントにトークンを得る。リダイレクトURIの完全一致検証PKCEはスキップ運用の前提インフラである
  2. プロトコルの制御パラメータを尊重することprompt=consentが来たら承認済みでも再提示する(クライアント側が明示的な再確認を求めるケース)、prompt=noneはスキップ可能なときだけ成功させ、同意が必要ならconsent_requiredエラーを返す。この2つはOIDCで規定された挙動なので、独自判断で上書きしない
  3. 同意の取り消し手段とセットであること。同意を永続化してスキップに使うなら、ユーザーが「連携済みアプリ一覧」を見て取り消せる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)を正確に記録し、その事実が消えたときに派生物(トークン)を確実に道連れにできるか」に帰着する。ワイヤ上のプロトコルが委任の「開始」を扱うのに対し、データモデルは委任の「終了」を扱っている——自作するなら、終わらせ方から設計するのが良いと思う。