OAuth 対応: 実測した 3 つの経路¶
翻訳について: 正本は英語版です。日本語版が古い場合は英語版を参照してください。
Status: 採用済み。両方の半分が実装済み。 選ばれたのは経路 (b) — resource server にとどまり、トークンの発行は外部の identity provider に委譲する — であり、あわせて 経路に依存しない半分を先に出荷することも決まりました (§7)。その半分は metadata ドキュメントを提供し、401 がそこを指します。もう半分も今は存在します: 列挙された issuer が署名し、まさにこの resource 向けに発行したトークンは principal へ解決され、 それ以外は拒否されます。どれも設定しない限り off のままです。
この文書が未決として残していた問い — provider が発行した識別子がどうやって grant を 獲得するか — は 呼び出し側ごとのプロビジョニングとして決着しました。それがコード上 どういう形を取ったかは §8 に記録してあります。§9 が述べる制約 — モデルの穴ではなく モデルの性質です — はしばらく未解決のままでしたが、その後モデルを弱めることなく答えが 出ました: subject 単位の境界を §12 に記録してあります。§13 は運用面の最後の一片を 閉じます: ゼロ設定の接続 — URL だけで、それ以外は何も要らない — を、稼働中の配備に 対して実測したものです。
却下した経路は削除せずに残してあります。選ばれたものだけを列挙する設計記録は、 他の 2 つがなぜ劣っていたのかを次の読み手に再発見させることになり、しかもそのうち 1 つは、失格の理由となる細部にぶつかる直前まで最も安く見えます。
ここで 実測 と記したものはすべて、何かを実際に走らせて得たものです — 生きた discovery ドキュメント、端から端まで駆動した本物の authorization フロー、あるいは ある検査が本当に効いているかを見るために動作するコードへ適用した変異、のいずれか です。ベンダーのドキュメントから取った主張はその旨を明記してあり、証拠としては より弱いものです。
1. 仕様が resource server に求めるもの¶
2026-07-28 の authorization リビジョンに照らすと、resource server が負う義務は 5 つです。これで全部であり、外から見えるよりも短いリストです。
- RFC 9728 の protected resource metadata を実装し、
authorization_serversに 少なくとも 1 エントリを持たせる。 - その metadata を discoverable にする。経路はどちらでもよい: 401 の
WWW-Authenticateヘッダに載せるresource_metadataパラメータか、well-known パスか。 - OAuth 2.1 §5.2 に従ってトークンを検証する。audience を含めて。別の resource 向けに発行されたトークンは、受理してはならず、転送してもなりません。不正または 期限切れは 401 です。
- 認証が必要なときは 401、scope が不足しているときは 403、リクエストが不正な形式の
ときは 400。403 は
error="insufficient_scope"をscopeおよびresource_metadataとともに運ぶべきです。 WWW-Authenticateヘッダでscopeを広告する。offline_accessは広告しない こと。
リビジョンの散文の上では我々の義務のように読める義務が 2 つありますが、それらは
クライアント側のものです: 保存した資格情報を発行元の authorization server で
キーイングすること、そして dynamic registration の際に application_type を指定
すること。どちらもこちらには来ません。
Dynamic Client Registration (RFC 7591) はこのリビジョンで非推奨となり、Client ID Metadata Documents に取って代わられました。生き残っているのは、CIMD より前の authorization server との互換性のためだけです。新規の実装をこれを軸に設計すべきでは ありません — ただし次節を見てください。クライアントが何を選好するかと、何が できるかは別の問いであり、我々はその両方を実測したからです。
2. クライアントが実際にやること¶
仕様のリビジョンが記述するのは、何が許されるかです。許された挙動のうち、目の前の クライアントがどれを選ぶかは教えてくれません。我々は、2 つの registration 世代を 同時に広告するサーバーを立て、そこへ Claude の web クライアントを接続しました。
両方の世代を広告した状態では、クライアントは CIMD を選び、registration には一度も
触れませんでした。 観測されたシーケンスは、resource_metadata を運ぶ 401、次に
protected-resource ドキュメント、次に authorization-server ドキュメント、そして
/authorize でした。POST /register は一度も呼ばれませんでした。authorization
リクエストについては:
client_idは https URL でした — これがあるからこそ、登録済みクライアントでは なく CIMD なのです。- PKCE (
S256)、加えてstate。 resourceは我々の canonical URI を運んでいました。 クライアントは本当に RFC 8707 の resource indicator を送ってきます。したがって audience バインディングは 理論上の話ではなく、我々に実際に使えるものです。scopeは、我々の 401 が広告した値そのものでした。 したがって scope の設計は 我々が決めるものです。クライアントは resource server が求めたものを採用します。- クライアントが公開している metadata ドキュメントは
token_endpoint_auth_method: "none"を宣言しています — シークレットを持たない public client です。
CIMD を広告しない状態では、同じクライアントが dynamic registration にフォール
バックします。 古い世代だけを広告して同じ実行を繰り返したところ、POST /register
が届きました — しかも 2 回。registration のボディは token_endpoint_auth_method:
"client_secret_post"、すなわち confidential client を要求していました。
はっきり述べておく価値のある帰結が 2 つあります。逆に取り違えやすいからです:
- クライアントの CIMD 選好は選好であって、能力の限界ではありません。古い世代 だけを広告する authorization server でも、依然として動きます。
- 同じクライアントが、どちらの世代を発見したかによって異なる性格を見せます — CIMD の下では public、registration の下では confidential。authorization server の 実装は、自分が広告したほうを扱えなければならず、また registration の繰り返しに 耐えなければなりません。
3. 3 つの経路¶
(a) 自分が authorization server になる。 discovery、authorization、token、そして ユーザーに向き合う consent の区間まで、すべて自前で提供します。
(b) resource server にとどまり、外部の identity provider に委譲する。 我々は トークンを検証するだけで、それ以外は何もしません。registration、サインイン、consent、 発行、refresh は provider が担います。
(c) 前段に access proxy を置く。 zero-trust proxy がエッジで authorization を 終端し、その背後のサーバーには proxy が納得したあとでなければ到達しません。
4. 比較¶
実装の数値は、(a) と (b) の両方を SDK に対して実際に構築して走らせた結果であり、 SDK を読んで出したものではありません。
| (a) authorization server | (b) 外部 provider | (c) access proxy | |
|---|---|---|---|
| 自前のコード、動作する最小版 | 114 行 | 65 行 | なし |
| 実装すべきプロトコルメソッド | 9 | 1 | なし |
| 永続化しなければならないレコード型 | 4 (40 フィールド) | 0 | なし |
| ユーザーに向き合うサインインと consent | 自前で構築 | provider のもの | proxy のもの |
| 新規のランタイム依存 | なし | なし | なし |
| 自分たちが持つ運用面 | discovery、発行、refresh、失効、consent、鍵素材 | トークン検証 | proxy の構成 |
| 外部アカウントなしで到達可能 | はい | いいえ | いいえ |
| 他の経路と併用可能 | — | — | (a) および (b) と排他 |
114 は下限であって、見積もりではありません。 その版は状態を辞書に保持し、 consent を自動承認し、ランダムな文字列を発行します。出荷できる版はそこに、4 つの レコード型のための永続ストレージ、背後にセッションを持つ consent ページ、そして CIMD の全体を加えます。65 のほうは完成に近く、そこに欠けているのは provider の選択 であって、追加のコードではありません。実際の非対称性は、この 2 つの数字の比よりも はるかに大きいものです。
比較のために言うと、SDK 自身のサーバー側 authorization パッケージは約 1,650 行です。 経路 (a) は「114 行を書く」ことではなく、「その問題のうち SDK が置き去りにした部分を 自分で持つ」ことであり、§5 がその一覧です。
5. SDK がやることと、置き去りにすること¶
カバー済みとして挙げるのは、我々が実際に誘発して観測した挙動だけです。誰も発火させて いない検査は、検査が存在することの証拠になりません。
カバー済み — PKCE の検証、登録済み集合に対する redirect URI の照合、構成済み
集合に対する scope の検証、client 認証、plain challenge method の拒否、
authorization code の単回使用、refresh token のローテーション。
トークンを発行または検証するいずれの経路でも、我々に残されるもの:
- audience の検証。 bearer バックエンドが検査するのは verifier の答えと有効期限
だけで、それ以外は何も検査しません。我々は自前の verifier から audience と issuer の
検査を取り除いて再実行しました: 別の resource 向けに発行されたトークンが 200 で
受理され、その identity が下流へ公開されました。仕様の「受理してはならない」は、
完全に我々が守るべきものです。それを守っているのが
cpersona/oauth.pyであり、同じ 変異を出荷コードにも当てました: audience の比較を無効にすると、2 本のテストが落ち、 それ以外は落ちません。取り除いても何も壊れない検査は、検査ではありません。 - 401 に載せる
scope。 ミドルウェアが出すのはerror、error_description、resource_metadataであって、scopeは出しません。クライアントは 401 が広告した scope をそのまま採用する (§2) のですから、このパラメータは装填済みのレバーです: 実際の接続試行 (2026-08-31) は、広告した値が issuer の定義しない scope を名指して いたためにinvalid_scopeで issuer に弾かれました — 利用者はサインイン画面にすら 到達していません。したがって既定は何も広告しません。scope 設計を持たないサーバーに、 ここで言える真実はないからです。
経路 (a) では、さらに我々に残されるもの:
- CIMD の全体。 SDK の client ID metadata document 対応は、まるごとクライアント側
のものです: 該当フィールドは共有モデルとクライアントパッケージには現れますが、
サーバーパッケージの下にはどこにもありません。対照として、そこには
client_idが 7 ファイルに現れるので、検索は届いています — 不在は本物です。その帰結は具体的で、 しかも見落としやすいものです: 素の SDK 上に構築した authorization server は CIMD を広告しないので、クライアントは非推奨の registration 経路にフォールバックします。 非推奨でないほうの経路を得るということは、広告、metadata ドキュメントの取得、 client-id が URL と一致することの検査、redirect URI の検証、構造の検証、キャッシュ、 そして request forgery への配慮を、自分で書くということです。 - issuer パラメータ (RFC 9207)。 出力されず、authorization server metadata も
authorization_response_iss_parameter_supportedを広告しません。どちらも我々が 足すものです。 - consent。 存在しません。
authorize()は「リダイレクト先の URL を返せ」と言う 契約であって、人間が見るページ、その背後のサインイン、判断の記録、そして戻りを 受け取るハンドラは、いずれもその戻り値の先にあります。SDK 自身のドキュメントも、 実装側が別のハンドラを定義する必要があると述べています。 - authorization code、access token、refresh token、client のための永続ストレージ。
6. 経路 (c) と、「サーバー側の変更なし」が正確には正しくない理由¶
我々は使い捨ての zero-trust 配備を作り、そこへクライアントを接続しました。動きます:
未認証のリクエストは resource_metadata を運ぶ 401 とともにエッジで拒否され、
metadata は解決し、dynamic registration は 201 を返し、/authorize は proxy の
サインインへリダイレクトし、consent 画面も正しく描画されます。proxy が納得するまで、
origin には何も届きません。
ただし 3 点の留保があります:
- proxy はクライアントに opaque token を渡します。静的な bearer を比較して認証 するサーバーは、それを拒否します。静的な資格情報を外すか、サーバーが proxy の assertion を受け入れられるようになるか、どちらかです — つまりこの経路もサーバーに 触れます。触れる量が少ないだけです。
- ベンダーはこの排他をそのまま文書化しています: 自前の OAuth と自前の
WWW-Authenticateに依存する配備は、マネージド版を有効にしてはなりません。 経路 (c) は (a) とも (b) とも共存できません。したがってこれは踏み石ではなく、 後戻りのきかない選択になります。 - 我々が観測した consent の nonce は、寿命がおよそ 5 分です。遅いサインイン要素 — たとえばメールで届くコード — と組み合わさると、初回接続が本人の完了を待たずに 期限切れになりえます。これは、この取り組み全体が手に入れようとしているリーチ そのものへの直撃です。
7. 選択に依存しない部分¶
resource server 側の半分 — RFC 9728 の metadata と、resource_metadata および
scope を運ぶ 401 — は、3 つすべての経路で必要とされます。これは加算的です:
既存の呼び出し側の挙動を一切変えません。今日すでに、有効な資格情報を持たない
リクエストはすべて拒否されているからです。
これは、すでに説明のついている症状も直します。この半分が出荷される前のサーバーは どちらの discovery 機構も実装していなかったため、仕様に準拠したクライアントは何も 見つけられず、自分の registration のはしごの最後の段まで落ちていました: 人間に client id を手で入力させる、という段です。あの画面は我々の資格情報が拒否された ということではなく、クライアントの欠陥でもありません。発見すべきものを何も 与えられなかったクライアントとしては、正しい挙動です。
この半分は経路が選ばれる前に出荷でき、どちらに転んでも失敗を読めるものにします。
有効化の方法。 環境変数 3 つで、いずれも既定は未設定です。どれも設定しなければ 応答はそれ以前とバイト単位で同一であり、これが経路選択に先行して出荷することを 安全にしています:
| 変数 | 役割 |
|---|---|
CPERSONA_OAUTH_RESOURCE |
このサーバーが公開し、クライアントから返されることを期待する正規のリソース識別子。空の間は discovery は無効のまま |
CPERSONA_OAUTH_AUTHORIZATION_SERVERS |
空白またはカンマ区切りの issuer URL。1 つも列挙されていない間は discovery は無効のまま |
CPERSONA_OAUTH_SCOPES |
401 で広告する scope (既定は空 — issuer が定義する scope だけを広告する。定義されない scope は全認可を invalid_scope で終わらせる) |
CPERSONA_OAUTH_JWKS_URI |
このサーバーが metadata を読めない provider のための、issuer の署名鍵の所在。通常は discovery で解決される。issuer が複数あるときは無視される |
上の 2 つは検証も同時に有効にします。誰に対しても開かない扉は、誰にも見つけられない 扉と同じ失敗だからです。検証にはさらに ACL モードが要ります — 理由と、ACL が無いとき サーバーが何をするかは §8 にあります。
既定値と周辺の設定は 設定リファレンス を参照してください。ここで 名前を挙げていること自体に意味があります — 上記の失敗を踏んだ運用者は、どうすれば よいかを知るためにこの節を読むのであり、スイッチが書かれていない機能は、出荷しても 出荷したことにならないからです。
8. 加算的な設計と、「加算的」が成り立たなくなる 1 箇所¶
ここでの authorization が消費するのはちょうど 1 つ、client 識別子を運ぶ principal だけです。強制層はヘッダを一切読みません — これは全参照箇所を読んで確認し、さらに 実際に駆動して確認しました。したがって新しい identity provider とは、その principal の新しい生産者であり、下流は何も変わりません。
我々は合成した resolver を作って走らせました: 約 15 行で、まず既存の資格情報 テーブルを試し、次に静的な資格情報、そしてトークン verifier を試します。OAuth を 有効にしても、既存のどちらの資格情報を使う呼び出し側もこれまでとまったく同じに解決 されます。OAuth トークンはその client へ解決されます。別の audience 向けのトークンと 未知の資格情報は、どちらも拒否されます。この順序は正しさのためではなく、コストと 攻撃面のために選んだものです — ローカルの比較は安く、リモートのトークンに騙される ことがなく、トークンのパースは攻撃者が制御する入力を読む唯一の段なので、最後に 置きます。
成り立たなくなる箇所。 identity の継ぎ目は差し込むだけで済みますが、grant テーブルはそうではありません。grant テーブルが一度も見たことのない識別子は空の grant 集合へ解決されるので、認証は通り、そのうえでスコープを持つツールをことごとく 拒否されます。これは lookup から推論したのではなく、本物の guard を駆動して確認し ました。これは正しい fail-closed の挙動ですが、同時に、OAuth を有効化することが運用 上は純粋に加算的ではないことを意味します: provider が発行した client 識別子がどう やって grant を獲得するのか — 既定値か、マッピング規則か、呼び出し側ごとのプロビ ジョニングか — を誰かが決めなければなりません。この決定は選ばれた経路に属するもの であり、実装中に発見されるべきものではありません。
決定は、呼び出し側ごとのプロビジョニングでした。 既定の grant は issuer に到達 できる者全員に権限を渡すことになり、issuer 単位の規則は「そこにアカウントがある」を 「この記憶を読んでよい」と同義にしてしまいます — どちらも認可の決定を、誰も書き留めて いない場所に置きます。そこで運用者が 1 行を足す形にしました。そこから 3 つの帰結が 続きます。発見されるのでなく、先に述べておくべきものです:
- 識別子は issuer で名前空間化されます —
oauth:<issuer>:<client_id>の形です。素の client id は、それを発行した authorization server に対してしか意味を持ちません: そのまま使うと静的に設定された client と衝突しうるし、provider を乗り換えたあと、 旧 provider 用に書かれた行が新 provider の同名 client を黙って認可してしまいます。 名前空間はさらに、すべての行とすべての拒否に出自を載せます。 - その行は資格情報を持ちません。
"token": nullは、呼び出し側が提示するのではなく resolver が主張する principal を宣言します (docs/ACL_DESIGN.md)。代わりにダミー文字列を 書くと、誰も使うつもりのない生きた静的 bearer が 1 本できます — 実測済みであり、 明示的な null が存在する理由です。 - grant テーブルが無ければ検証は動きません。 ACL ファイルが無ければプロビジョニング する先が無く、検証済みトークンは背後に何の強制も無いまま認証を通り、すべてのツールへ 届いてしまいます。黙って劣化させる代わりに、サーバーは検証が off であることをログに 書き、discovery は提供し続けます: クライアントは issuer を見つけ、そのうえで拒否される — これは真実の答えです。
トークンが解決される identity は client_id クレーム、provider がそちらを送る場合は
azp から取ります。どちらも持たないトークンは sub へフォールバックせず拒否します:
principal は契約上 client 識別子であり (§9)、そこへ subject を入れると 2 種類の identity が
1 つの名前空間を共有し、grant 行の意味が「provider がたまたまどのクレームを送ったか」に
依存してしまうからです。
9. 経路の選択より長生きする制約¶
principal が運ぶのは client 識別子だけで、それ以外は何も運びません。
CIMD の下では client id は固定の URL であり、registration の下では registration ごと に 1 つの値です。どちらにせよ、そのクライアントのエンドユーザーは全員、単一の 識別子へ潰れます。 トークンは確かに subject を運んでおり、verifier も確かにそれを 表に出します — 落としているのは継ぎ目のほうです。
したがってクライアント別の authorization では、マルチテナントなクライアントの エンドユーザーどうしを分離できません。これは実装チケットではなく本文書で述べておく 価値があります。そもそも OAuth を足す動機はリーチ — 多くの人々、そしてその大半は まさにそうしたクライアント 1 つを通ってやって来る — だからです。リーチとクライアント 別の authorization は緊張関係にあり、その緊張は現在の実装の欠落ではなく構造的な ものです。
この節を最初に書いたとき、本文書はこの緊張を解決せず、ただこれに驚かされないでいる ことを提案していました。その後これは解決されました: §12 が subject 単位の境界を 記述しており、上の制約はそのまま保たれます — principal の client identity は 依然として client 識別子であってそれ以外の何物でもありません — そして 2 種類の identity を 1 つの名前空間に混ぜるのではなく、その隣に検証済みのフィールドをもう 1 つ 置くことで人を分離します。
10. 対象外¶
- ~~同一クライアントのエンドユーザーどうしを分離すること (§9)。~~ 解決済み — §12。
- アカウントをメモリのエージェント identity に紐づけること。
- 既存の呼び出し側を現在の資格情報から移行させること。それらは動き続けます。それが §8 の要点です。
- トークンの
scopeを強制すること。 §1 は resource server の義務としてinsufficient_scopeを伴う 403 を挙げていますが、この実装はそれを返しません。認可は grant テーブルの仕事であり、scope を第 2 の独立した権限モデルとして読むと、1 つの問いに 2 つの答えが生まれ、食い違ったときにどちらが勝つのかの規則がありません。401 に載せる scope は §2 が実測したレバーのままです — クライアントが issuer に何を要求すべきかを 伝えるのはこれです — そしてトークンの scope は検証済み identity に載せて運んでいるので、 第 2 のモデルは検証の中身を変えずに後から足せます。 - provider の選定全般。候補の比較は別の作業であり、その結論は、いずれかの候補を §8 に 記した verifier に対して本物のトークンで実際に動かすまでは、ドキュメント水準の話に とどまります。
11. 決定と、そこで未決だった問い¶
経路 (b)。 9 つではなく 1 つのプロトコルメソッドを実装し、何も永続化せず、 consent の面を作らず、依存も足しません。代償は認証経路に外部サービスが入ることで あり、これは本物の代償です: そこで障害が起きれば、誰も認証されません。
経路 (a) を却下した理由は、114 行ではなく SDK が置き去りにするものの形です。ここで authorization server を持つということは CIMD をまるごと持つということであり、素の SDK は代わりに非推奨の registration 経路を黙って提供します (§5) — したがって経路 (a) の正直な版は、メソッド数から誰もが見積もる版よりも大きくなります。
経路 (c) を却下した理由は、コストではなく排他性です。コードは最も少ないのですが、 自前の OAuth を提供する配備とは共存できないとベンダーが文書化しており (§6)、これを 選ぶと他の 2 つへの扉が閉じます。取り消せない経路は、これほど若い設計における最初の 一手としては拙いものです。
経路に依存しない半分を先に出荷します (§7)。これは加算的で、どの経路が勝っていた としても必要であり、誰にも読めない失敗を、自分で説明する失敗に置き換えます。
決着: provider が発行した client 識別子は、呼び出し側ごとのプロビジョニングで grant を 獲得します (§8)。既定値や issuer 単位の規則は、認可の決定を誰も書き留めていない場所に 置くことになります。この節が警告していたコストは残っており、消えてはいません — grant を 持たない識別子は認証に成功し、そのうえで何もかも拒否され、正しい挙動でありながらバグと まったく同じに見えます。足したのは、その 2 つを見分けさせる一文です: grant テーブルが 一度も見たことのない client への拒否は、そう述べるようになりました。意図的に与えられ なかった権限と、同じ文面で返ることはもうありません。
12. subject 単位の分離: 境界、台帳、そして @me¶
§9 は制約を名指ししました: マルチテナントなクライアントのエンドユーザーは全員 1 つの client 識別子へ潰れるので、grant 行 1 つが「そのテナントにサインインできる者は全員 1 つの 記憶を共有する」を意味してしまいます。これは静かな形の過剰付与です — 症状はなく、挙動は 正しく見え、そして OAuth を足す動機となった配備にとってはまさに間違った既定です。この節が その解決です。3 つの決定がそれを担っており、いずれも具体的な代替案と突き合わせて選ばれて います。
principal は改名ではなく構造化されます。 Principal は (client_id, issuer,
subject) を運ぶようになりました。subject は検証済みの sub クレーム — 人に対して安定な
唯一の識別子であり、(issuer, subject) の対としてのみそう言えます (OIDC Core §5.7)。
代替案は subject を agent の名前空間へ畳み込むこと (agent_id としての user-<sub>) で
したが、§8 が client_id から sub へのフォールバックを拒む理由と同じ理由で却下しました:
2 種類の identity が 1 つの名前空間にあると、すべての行の意味が「たまたまどちらの種類が
届いたか」に依存してしまいます。静的な resolver は新しい 2 つのフィールドをどちらも空の
ままにします — そこでは誰も人を保証していないので、何も人を主張しません。
分離は加算的な grant ではなく、制限的な境界です。 client の行は "per_subject": true
を宣言できます。そのクライアントからは、サインインした各 subject は自分自身の alias 空間
だけに到達します。それ以外のすべての agent 名は grant テーブルを参照する前に拒否される
ので、明示的な拒否が "*" ワイルドカードを含むあらゆる許可に勝ちます。代替案 — subject を
追加の grant 行としてモデル化する — は 2 通りに fail open します: 便宜のために
"*": "read-write" と書いた運用者は、subject をまたぐ到達を黙って与えてしまいますし、
誰も行を書かなかった subject は設定漏れと区別がつきません。差し引く境界は、ファイルの
他の場所での気前のよさによって広げられることがありません。このフラグは resolver が主張する
行 ("token": null) でのみ受理されます: 静的トークンが認証するのは client であって人では
ないので、そこでのフラグは決して適用されえないポリシーになります。ローダーはそれを不活性の
まま放置せず、起動時に拒否します。
subject には、このサーバーが発行する opaque な alias が与えられます。 subject の記憶が
置かれる名前は生の sub ではなく短い opaque な alias (u- + hex) であり、
(issuer, subject) → alias の対応は alias 台帳 — データベースの隣に置かれ、サーバーが
書き込める JSON ファイル (CPERSONA_ALIAS_LEDGER_FILE で移動可能) — に永続化されます。
生の subject を agent_id に焼き込む案は却下しました。subject を再発行する provider 側の
イベント — 移行、カスタムドメインへの移動、pairwise 識別子への切り替え — が起きるたびに、
旧値でキーイングされたデータが孤立し、それを修復する継ぎ目がないからです。台帳があれば、
それらのイベントはどれもファイル 1 つの編集になります: 2 つの (issuer, subject) 行を
1 つの alias に向けることが手動のアカウント連携であり、これが運用者の脱出ハッチです。
台帳は grant テーブルとは意図的に別のファイルで、書き手も異なります: acl.json は運用者が
書きサーバーが決して触れないポリシーであり、台帳はサーバーが書き運用者が編集しうる状態です。
発行は自動です。 新しい subject が自分自身にスコープする最初の呼び出しが alias を発行し、
永続的に記録し、ログに書き、そして応答で報告します (alias_issued: true)。招待状の形をした
代替案 — 運用者が subject ごとに事前作成する — は、§8 が client について記録したのとまったく
同じ失敗を人ごとに再現するため却下しました: 認証には成功し、そのうえで何もかも拒否され、
正しい挙動でありながらバグとまったく同じに見えます。書かれた per_subject フラグが依然
として opt-in であり (行が無ければ分割も無い — §8 の哲学はそのままです)、入口のゲートは
アカウントが管理される場所に属します: そもそも誰がサインインできるかは identity provider の
テナント設定が決めます。永続化できない発行は、そのまま進めるのではなくリクエストを拒否
します — 書き込みを認可したのに再起動で消える alias は、その書き込みを新しい alias の
向こう側に取り残してしまうからです。
@me は呼び出し側自身の空間を指します。 subject は、サーバーが発行するまで自分の
alias を知りえないので、番兵 @me (既存の @auto と同じ語法) がそこへ解決されます。
評価順は固定です: 解決、次に ACL、次にクエリ — 番兵は要求が算出される前に書き換えられる
ので、リテラルに対して grant が評価されることはありません。そして応答は
resolved_agent_id (alias であって、生の subject では決してありません) を返すので、何に
到達したかが可視かつアドレス可能になります。番兵が何かを広げてしまわないよう、4 つの拒否が
あります:
- 境界を持たない client — stdio の principal、あらゆる静的トークンの client、フラグの無い
行を持つ OAuth client — は
@meを端的に拒否されます。client identity へフォールバック すれば、番兵が黙って「自分の client」を意味しうることになり、それはこの設計が避けるために 存在する名前空間の混在です。 actクレーム (RFC 8693 の委譲) を運ぶトークンは検証時に拒否されます: そこで名指しされた subject は呼び出し側ではなく、それを尊重すれば、なりすまされた subject の alias を委譲先に 手渡すことになります。per_subjectが設定された状態での起動時、予約名 — リテラルの@me、または alias 台帳に 記録の無いu-接頭辞配下の agent — をすでに使っているデータベースは、提供を拒否します: 保存された@me行は二度とアドレスできなくなり、台帳に無いu-agent は発行済みの alias と区別がつきません。台帳が記録している alias は免除されます: それはこのサーバー自身の過去の 発行物であり、拒否すると最初の実利用の後の最初の再起動が正当なデータで失敗しました (2026-08-31、本番で実測)。一度も opt-in しない配備は、持っている名前をすべてそのまま 保てます。- metadata が pairwise の subject 識別子だけを宣言している issuer は、
per_subjectが 設定されている間は拒否されます: pairwise なsubが名指すのは (人, client) の対であって 人ではないので、台帳のキーは 1 人の記憶を client ごとに黙って分割してしまいます。この フィールドが無いことは宣言ではなく — RFC 8414 の metadata はこれを運びません — またCPERSONA_OAUTH_JWKS_URIは metadata を完全に迂回するので、両方を設定すると検査を実行 できない旨の起動時警告が出ます。
これが意図的にやらないこと。 データベースの軸は増やしません: 境界は 1 箇所 (ACL の
guard) で強制され、その下では alias はどこでも普通の agent_id なので、メモリのスキーマ、
移行の筋書き、そしてすべてのクエリは手つかずのままです。subject を別々のデータベースへ
振り分けること、owner/tenant カラム、identity 連携テーブルはいずれも検討のうえ見送りました:
どれもこの境界がすでに提供している分離を、ストレージ層に手を入れる代償で買うものであり —
現時点の線としては誤ったトレードで、どのみち次にスキーマを開けるときに再評価できます。
ある client の分割を有効にしても、ファイル内の他のどの行にも影響はありません。per_subject
の行が無い配備は、以前とバイト単位で同一に動きます。
13. ゼロ設定の接続 (実測)¶
決定: 接続に必要なのは URL だけで、それ以外は何も要りません。 client id の貼り付けも、 secret も、利用者ごとの設定もありません。connector プラットフォームは事前登録済みの client id を静的資格情報として受け付けます — それ自体は正当な選択肢で、それが解決する 「安定した識別子」の問題も実在します — が、ここでは不採用にしました。その id は、接続する すべての人に運用者が手渡さなければならない値だからです。この取り組みが買おうとしている 到達範囲 (§9) は初回体験の最悪のステップで頭打ちになり、「この 2 つ目の値も貼り付けて」は まさにそのステップになります。
ゼロ設定が呼び出し側ごとのプロビジョニング (§8) と両立するのは、接続が提示する識別子が 安定している場合だけです。これは仮定ではなく、稼働中の配備に対して 2 段階で実測しました (2026-08-31):
- 動的登録を provider 側で無効化しました。 provider のダッシュボードは 2 つの登録世代を 独立したスイッチとして提供しており、非推奨の経路 — connector プラットフォーム自身の ドキュメントが新しい接続のたびに新規 client を登録すると書いている経路 — は、許容 するのではなく構造的に閉じられます。metadata document を提示できないレガシーな client は 接続できなくなりますが、この配備が相手にする client はすべて提示できます (§2)。
- その上で connector を削除し、URL だけで再追加しました。 サインインが完了し、スコープ 付きのツール呼び出しが grant テーブル無変更のまま通りました。grant テーブルが見た ことのない識別子を拒否することが実測済みの verifier (§8) の下で、無変更のテーブルが スコープ付き呼び出しを通したことは、再登録された client がテーブルに既にある識別子へ 解決された証拠です: provider は metadata-document client に自前の識別子を発行し、再登録を 跨いで安定に保ちます。client アプリケーションごとに grant 行 1 つで足り、書くのは一度 だけです。
行を書くのは依然として初回接触の後であって、前ではありません: hosted client の metadata URL は公開されていないため、最初の接続は観測されます — 未知の識別子を名指しする拒否が、 ログと拒否応答そのものに現れます (§11) — そしてその後に provision します。観測してから provision する一回きりのコストは client アプリケーションごとのものであって、人ごとでは ありません。人ごとの半分は subject 境界 (§12) が既に担っています。
アカウント作成は明示的なままです。provider 側で social サインインを有効にすると、初回 接触は「URL を貼る → サインイン方法を選ぶ → identity provider の同意画面を承認する」に なります。テナント上のアカウントはこの 2 回の意図的なクリックの産物であり、後から開示 すべき無断の登録ステップは存在しません。配布にあたって必要になるのは、サインインページが サービス自身の名前と、同意の先で何が保存されるかを述べた規約へのリンクを掲げることです — どちらも provider ダッシュボードの設定であり、どちらも URL を現在の利用範囲を超えて配る 前提条件です。入り口のゲートそのものは §12 が置いた場所に留まります: 誰がそもそもサイン インできるかはテナントのサインアップポリシーが決めます。