ユースケース — クライアントオンボーディングのパターン
「OAuth / OIDC のクライアントを作る」と一口に言っても、運用上の形は 5 通りあり、IETF / OIDF が仕様化しているのはそのうち 1 つだけです。このページはその地図です。各パターンが活きる場面、ライブラリが提供する差し込み口、そして付随するセキュリティの責任範囲を整理します。標準化された経路は 動的クライアント登録 のみで、残りの 4 つは運用設計の領域です。「ライブラリの責任」と「組み込み側の責任」の境界は、思っているよりも明確に分かれています。
このページで触れる仕様
全体像 — 5 つのパターン
| パターン | 信頼の起点 | メタデータを書く主体 | 向いている場面 |
|---|---|---|---|
| 静的登録 | 運用者の設定 | 配備時に運用者 | 内部アプリの固定的な一覧 |
| IaC / GitOps プロビジョニング | 運用者の CI パイプライン | パイプラインが store.ClientRegistry 経由で書く | RP 定義をソース管理に置く構成 |
| 標準化された DCR (RFC 7591 + 7592) | 運用者発行の IAT、クライアント単位の RAT | RP 自身が /register で登録 | マルチテナント SaaS、開発者セルフサービス |
| scope で保護した管理 API | 管理用クライアントの AT(scope と claim で境界を定義) | 組み込み側の HTTP ハンドラ → store.ClientRegistry | 中央コントロールプレーン / IDM GUI |
| 仕様外の経路を使う CLI / 管理ツール | 仕様外の認証(通信路上には認証なし) | 管理用バイナリがストアを直接書く | SRE のバッチ取り込み、リカバリ |
標準仕様だけを追うのであれば 3 行目で読むのを止めて構いません。4 行目と 5 行目は運用設計の領域です — 本ライブラリは差し込み口(store API)は公開しますが、その上に乗るエンドポイント自体は提供しません。セキュリティの責任範囲がアプリケーションごとに異なるためです。
1. 静的登録
最もシンプルな形で、多くの組み込み側がここから始めます。op.WithStaticClients は op.ClientSeed ビルダーの可変長リストを受け取り、各ビルダーは構築時に store.Client レコードへ射影されます。レコードは Source: ClientSourceStatic で固定されるので、ファーストパーティ自動同意の対象になります。
pubJWKS, err := op.LoadPublicJWKS("conformance/keys/fapi-client.jwks.json")
if err != nil { /* ... */ }
provider, err := op.New(
/* 必須オプション */
op.WithStaticClients(
op.ConfidentialClient{
ID: "billing-app",
Secret: "billing-app-secret-at-least-32-bytes-0123", // 32 バイト以上
AuthMethod: op.AuthClientSecretBasic,
RedirectURIs: []string{"https://billing.example.com/callback"},
Scopes: []string{"openid", "profile"},
GrantTypes: []string{"authorization_code", "refresh_token"},
},
op.PublicClient{
ID: "billing-spa",
RedirectURIs: []string{"https://app.example.com/callback"},
Scopes: []string{"openid", "profile"},
},
op.PrivateKeyJWTClient{
ID: "fapi-rp",
JWKS: pubJWKS,
RedirectURIs: []string{"https://rp.example.com/callback"},
Scopes: []string{"openid"},
},
),
)op.PrivateKeyJWTClient は、インラインの JWKS か JWKSURI のどちらか一方を取ります。相互 TLS クライアント認証(feature.MTLS)では、op.TLSClientAuthClient が証明書の matcher(SubjectDN、SANDNS、SANURI、SANIP、SANEmail)をちょうど 1 つ、op.SelfSignedTLSClientAuthClient が JWKS と JWKSURI のどちらかをちょうど 1 つ取ります。
シードの射影は、DCR が POST /register の段階で適用しているのと同じ redirect URI 形式のルールを op.New の時点で適用します。形式に違反するシードは構築時に失敗するので、ランタイムまで漏れ出すことはありません。ConfidentialClient.Secret は op.HashClientSecret(argon2id)でハッシュしてからストアに到達するため、平文がストアに永続化されることはありません。client_secret_basic / client_secret_post のクライアントには Secret か、計算済みの SecretHash のどちらか一方が必須です(同時指定は不可)。どちらも空だと op.New が拒否します。空のままでは、空の client_secret で認証が通ってしまうためです。平文の Secret が 32 byte に満たない場合も拒否されます(OpenID Connect Core §16.19)。op.NewClientSecret で条件を満たす secret と hash を生成できます。op.WithHighEntropyClientSecrets() を使う場合、Secret は常に argon2id でハッシュされるので、op.NewClientSecret か op.HashHighEntropyClientSecret で作った SecretHash を渡してください。
永続バックエンドが相手の場合、シードは store.StaticClientReconciler を通じてひとつの原子的かつ冪等なバッチとして適用されます。存在しないレコードは挿入し、内容が同じレコードはそのままにし、保存済みレコードと食い違う場合や動的登録されたクライアントと衝突する場合は ErrConflict で失敗します。この突き合わせは他のすべての失敗しうる構築処理の後に走るため、無関係な理由で起動が失敗したときに一覧の一部だけが書き込まれることはありません。シードを変えずに OP を再起動するのは、書き直しではなく no-op になります。突き合わせは、一覧から外れた保存済みの静的クライアントを削除しません。
静的クライアントを変更する: op.SyncStaticClients
起動時は、保存済みのレコードと食い違うシードを拒否します。そのため、静的クライアントの編集(redirect URI の追加やシークレットのローテーション)には明示的な手順が必要です。デプロイジョブか管理コマンドから、op.New の前に op.SyncStaticClients を実行します。渡すシードは、プロバイダの構築に使うものと同じにします。
results, err := op.SyncStaticClients(ctx, storage, staticClients()...)
if err != nil { /* ... */ }
for _, r := range results {
log.Printf("client %s: %s", r.ID, r.Action) // inserted、updated、unchanged のいずれか
}存在しないクライアントは挿入し、メタデータやシークレットが異なる静的クライアントは更新し、同一のものはそのままにします。保存済みのクライアントの出所が static でない場合(動的登録または管理者作成)は、触りません。シードがそのようなクライアントと衝突すると、store.ErrConflict をラップしたエラーを返し、何も書き込みません。書き込みは原子的ではありませんが、途中で失敗しても再実行すれば収束します。SyncStaticClients は削除しません。一覧から外したクライアントは ClientRegistry.DeleteClient で廃止します(パターン 2 か 5)。クライアントの認証情報を書き換えるため、HTTP ルートには公開しないでください。ストアに書き込み可能なクライアントレジストリが必要で、無ければ構成エラーになります。同期の後は、op.New の突き合わせで変更が見つかりません。
ソース:
examples/53-production-sqlはsync-clientsコマンドからop.SyncStaticClientsを実行します。同期しないと、編集したクライアントは起動時に拒否されます。
ローリングデプロイ
突き合わせは op.New のたびに、どちらの向きでも行われます。同期がメタデータの変更や新しいシークレットを書き込んだ後は、古いシードで起動するレプリカの op.New が構成エラー(ErrConflict)で失敗します。同期の前に新しいシードで起動したレプリカも、同じように失敗します。すでに動いているレプリカは、確認が構築時にしか行われないため影響を受けません。そのため、同期の後には、旧バージョンのレプリカが再起動できない期間ができます。
- デプロイの手順として、ロールアウトの直前に新しいシードで
op.SyncStaticClientsを実行します。 - 新しいバージョンをロールアウトします。旧バージョンのレプリカは、置き換わるまで動かしたままにし、その間は古いイメージからの再起動やスケールアウトをしないでください。起動に失敗します。
- 同期の後で旧バージョンへ戻す場合は、先に古いシードで
op.SyncStaticClientsを実行します。
シードを変えないデプロイでは同期は不要です。マルチインスタンス運用も参照してください。
無停止でシークレットをローテーションする
Secret だけを差し替えると、新しい値をまだ受け取っていないクライアントのデプロイがすべて壊れます。confidential のシードは、現行のシークレットと並べて、期限付きで 1 世代前のシークレットを持てます。
op.ConfidentialClient{
ID: "billing-app",
Secret: newSecret,
PreviousSecret: oldSecret,
PreviousSecretExpiresAt: time.Now().Add(6 * time.Hour),
AuthMethod: op.AuthClientSecretBasic,
/* redirect URI、scope、grant は従来どおり */
}- 新しい値を
Secretに、置き換える値をPreviousSecretに、旧シークレットが使えなくなる時刻をPreviousSecretExpiresAtに設定します。 - 新しいシードで
op.SyncStaticClientsを実行してから、OP を起動します。同期の前は、他の編集と同じように、変更したシードをop.NewがErrConflictで拒否します。同期は、それぞれのシークレットのハッシュを保存します。旧の平文が、保存済みの現行ハッシュ(初回のローテーション)または保存済みの旧ハッシュ(再実行)で検証できれば、そのハッシュを引き継ぎます。そのため、初回の実行はupdated、2 回目はunchangedになります。 - クライアントに新しいシークレットを渡します。期限までは、クライアントを認証するすべてのエンドポイントで、両方のシークレットが通ります。対象は
/token、/par、/introspect、/revoke、/bc-authorize、/device_authorization、/grant_managementです。 - 監査ストリームで
client_authn.previous_secret(audit.ClientAuthnPreviousSecret)を監視します。クライアントが旧シークレットで認証するたびに warn レベルで出力され、client_idと認証方式のmethodを持ちますが、シークレットは含みません。そのクライアントについてこのイベントが届かなくなれば、すべてのデプロイが切り替わっています。 - シードから
PreviousSecretとPreviousSecretExpiresAtを外して、もう一度同期を実行し、旧ハッシュをストアから消します。期限を過ぎれば、この手順を忘れても旧シークレットは認証に使えません。
背後にある規則は次のとおりです。
PreviousSecretとPreviousSecretHashは排他で、どちらを使う場合もPreviousSecretExpiresAtが必須です。期限のない旧シークレットは、受理されません。期限が過去でもエラーにはならず、旧シークレットが無視されるだけです。- 期間は週ではなく時間単位に保ってください。 旧シークレットが有効な間は、誤ったシークレット 1 回につき Argon2id の検証が 1 回ではなく 2 回走ります。そのクライアントへの推測の大量送信が、OP にとって 2 倍のコストになります。期間が短ければ、漏れた旧シークレットが使える時間も短くなります。
- high-entropy のシークレット。
op.WithHighEntropyClientSecrets()を使う場合は、新しいシークレットをSecretHashに、旧をPreviousSecretHashに指定します(op.NewClientSecretまたはop.HashHighEntropyClientSecretで作ります)。平文のPreviousSecretは、Secretと同じく Argon2id のハッシュとして保存されてしまうため、op.Newが拒否します。op.SyncStaticClientsはこのオプションを見ないので、保存済みの現行ハッシュが high-entropy で、PreviousSecretがそれで検証できない場合、同期は一致するハッシュを作れません。エラーを返して何も書き込まず、メッセージでPreviousSecretHashを指定するよう案内します。 - 動的クライアントでは、この方法でローテーションできません。RFC 7592 には 2 つのシークレットを並行して持つ定義がないためです。RFC 7592 による更新でシークレットを消すか置き換えると、旧シークレットも破棄されます。シークレットを保つ更新では、旧シークレットも保たれます。
store.ClientRegistryで書き込むレコードでは、この 2 項目はPreviousSecretHashとPreviousSecretExpiresAtです。
クライアントシークレットを生成する: op-client-secret
go run github.com/libraz/go-oidc-provider/cmd/op-client-secret引数なしで実行すると、新しい高エントロピーのシークレットを生成し、secret: <平文> と hash: <ハッシュ> の 2 行を出力します。平文はクライアントに渡します。これが唯一のコピーです。ハッシュは ConfidentialClient.SecretHash に設定します。op.WithHighEntropyClientSecrets() が求めるのはこの形式です。-argon2id を付けると、標準入力から平文を 1 行読み、hash: <argon2id ハッシュ> を出力します。
printf '%s\n' "$SECRET" | go run github.com/libraz/go-oidc-provider/cmd/op-client-secret -argon2id平文の ConfidentialClient.Secret は常に argon2id のハッシュとして保存されます。そのため op.WithHighEntropyClientSecrets() の下では SecretHash で seed する必要があります。op.SyncStaticClients はこのオプションを参照しないので、平文のシークレットを同期しても、後の op.New が拒否します。
このパターンが向くのは、RP の数が少なく、OP と同じチームが運用していて、変更が他の設定と同じ配備パイプラインを通る構成です。
2. IaC / GitOps プロビジョニング
RP の一覧をソース管理(Terraform / Pulumi モジュール、Kustomize 上書き、Helm の values ファイルなど)に置くなら、マニフェストを読んで OP のストアを直接書くプロビジョニングバイナリが向いています。本ライブラリは store.ClientRegistry で書き込み口を公開しており、DCR をサポートするバックエンドはこのインターフェースを満たします。
storage, err := oidcsql.New(db, oidcsql.Postgres())
if err != nil { /* ... */ }
registry, ok := storage.Clients().(store.ClientRegistry)
if !ok {
return fmt.Errorf("backend does not support client writes")
}
for _, plan := range desired {
hash, err := op.HashClientSecret(plan.Secret)
if err != nil { /* ... */ }
c := &store.Client{
ID: plan.ID,
RedirectURIs: plan.RedirectURIs,
Scopes: plan.Scopes,
GrantTypes: plan.GrantTypes,
ResponseTypes: []string{"code"},
TokenEndpointAuthMethod: op.AuthClientSecretBasic.String(),
SecretHash: hash,
Source: store.ClientSourceAdmin,
}
if err := registry.RegisterClient(ctx, c); errors.Is(err, store.ErrAlreadyExists) {
err = registry.UpdateClient(ctx, c)
}
if err != nil { /* ... */ }
}ここでの区別子としては store.ClientSourceAdmin が適切です。ファーストパーティ自動同意の対象という観点では ClientSourceStatic と同じ扱いになりつつ、OP 自身が /register 経由で作成したレコードと監査ログ解析で識別できます。形式のバリデーションはプロビジョニングバイナリ側の責任です。RegisterClient は単にストアへ書き込むだけで、登録ハンドラ側のルールセットは実行されません。jwks と jwks_uri の両方を持つレコードは書かないでください。クライアントの鍵を読むあらゆる箇所(request object、private_key_jwt、レスポンス暗号化)で、OP が利用時に拒否します。OP プロセスがクライアント情報の in-memory キャッシュを保持するなら、無効化の手段(SIGHUP によるリロード、バイナリから呼び出す管理エンドポイント、バックエンドが提供する TTL 失効など)を用意します。
マニフェスト内のクレデンシャル素材は、他の配備時クレデンシャルと同じ扱いです — 暗号化された state、CI プリンシパルの最小権限、CI ログへの echo 禁止。
3. 標準化された DCR (RFC 7591 + 7592)
RP が組織境界の向こう側にいる場合 — マルチテナント SaaS のテナント、連携マーケットプレイスのパートナー、プラットフォームに登録する開発者 — RP が自分で呼び出せる JSON API が必要になります。このエンドポイント仕様を定義しているのが RFC 7591 / RFC 7592 で、op.WithDynamicRegistration がそれをマウントします。
provider, err := op.New(
/* 必須オプション */
op.WithDynamicRegistration(op.DynamicRegistration{
AllowedGrantTypes: []string{"authorization_code", "refresh_token"},
AllowedResponseTypes: []string{"code"},
}),
)
iat, err := provider.IssueInitialAccessToken(ctx, op.InitialAccessTokenSpec{
TTL: 1 * time.Hour,
MaxUses: 1,
})iat.Value は仕様外の経路で RP に渡します。TTL は短く、MaxUses: 1 を維持してください。IAT が漏洩してもリプレイされないようにするためです。RP は 201 応答で registration_access_token(RAT)を受け取り、自分の登録に対する RFC 7592 の read / update / delete だけに使います。RFC 7592 §2 はクライアント A の RAT がクライアント B の操作を承認してはならないことを明示しています。詳細(バリデータが適用するセキュリティ最低ラインや、往復するメタデータ項目)は 動的クライアント登録 を参照してください。
4. scope で保護した管理 API
これは SaaS ベンダ系のプロバイダが「管理 API」として同梱していることが多いパターンで、本ライブラリが意図的に同梱していないパターンでもあります。形は決まっています。特権を持つ「管理用クライアント」が client.read / client.write のような scope(必要なら tenant=acme のような claim も)を持つアクセストークンを取得します。組み込み側が用意した HTTP ハンドラがその AT を検証して scope と tenant 境界を解釈し、最終的に store.ClientRegistry を呼び出します。
管理用クライアントは /introspect を呼び出すため confidential でなければなりません。最小の M2M provider では op.WithGrants(grant.ClientCredentials) で client-credentials grant を有効にし、ブラウザ用の LoginFlow は設定しません。ブラウザ対応 provider なら authorization-code grant とログインフローを明示的に設定します。/introspect は通常この管理用クライアント自身の認証情報で認証します。別のクライアントが発行したアクセストークンをハンドラが検査する必要がある場合は、対象 resource の ProtectedResource.IntrospectionClients に管理用クライアントを追加してください。リフレッシュトークンはクライアント間で委譲されません。
// 1. 管理用クライアントの静的シード。
op.WithStaticClients(
op.ConfidentialClient{
ID: "control-plane",
Secret: "control-plane-secret-at-least-32-bytes-0123", // 32 バイト以上
AuthMethod: op.AuthClientSecretBasic,
GrantTypes: []string{"client_credentials"},
Scopes: []string{"client.read", "client.write"},
},
)
// 2. 組み込み側が所有する管理ハンドラ(スケッチ)。
func adminCreateClient(registry store.ClientRegistry) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
bearer := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
intro, err := postIntrospect(r.Context(), bearer) // /introspect への HTTP 呼び出し
if err != nil || !intro.Active || !hasScope(intro.Scope, "client.write") {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
var c store.Client
if err := json.NewDecoder(r.Body).Decode(&c); err != nil { /* 400 */ }
if err := embedderValidate(&c, intro); err != nil { /* 400 */ } // 後述の警告を参照
c.Source = store.ClientSourceAdmin
if err := registry.RegisterClient(r.Context(), &c); err != nil { /* エラーマップ */ }
// 監査イベントはここで発行 — このパスでは audit.DCR* 系は発火しません。
w.WriteHeader(http.StatusCreated)
}
}このハンドラ自体を本ライブラリがマウントすることはありません。公開 OP ホストに置くか、別の管理ホストに置くか、クラスタ内ネットワークだけに閉じるかは配備上の選択であり、いずれもセキュリティ境界そのものではありません。境界は、ストアに触れる前にハンドラが検証するアクセストークンです。したがって、以下のルールを強制する責任は組み込み側が負うことになります。
パターン 4 のセキュリティ責任範囲
- 管理 AT の漏洩 = scope 内のすべてのクライアントの乗っ取り。AT の TTL を短く保ち、管理用クライアントには送信者制約(DPoP または mTLS)を必須にして、Bearer 値が盗まれてもホスト外でリプレイできないようにします。
- 権限昇格ガード。呼び出し側 AT 自身が持っていない grant type / scope / FAPI 機能を持つクライアントを生成・昇格するメタデータは、ハンドラが必ず拒否しなければなりません。
store.ClientRegistryはハンドラ層を介さない単純な書き込みであり、このチェックは行いません。このチェックを省くのが、この種の API が CVE 級の脆弱性を抱える典型的な経路です。 - テナント境界。マルチテナントを持つ場合、境界は呼び出し側 AT の tenant claim をハンドラが読み、読み取りをフィルタし、書き込み範囲をテナントに絞ることでのみ成立します。本ライブラリにテナントの概念はありません。
- 監査ログ。すべての CRUD 操作を、呼び出し側の
client_idと対象のclient_idを添えて記録します。本ライブラリのaudit.DCR*イベントは/register(OP 自身が所有するパス)でしか発火しません — 組み込み側の管理 API はこのカタログには載りません。 redirect_uris等は自前で検証する。RegisterClientは DCR ハンドラが適用する RFC 8252 / OIDC Registration §2 のルールセットを実行しません。動的クライアント登録 → 譲れないセキュリティの最低ライン と 登録時に強制している内容 のルール一覧をミラーしてください —application_typeごとの redirect URI 形式、jwks/jwks_uriの同時指定不可、URI 系 field のhttps限定、sector_identifier_uriの包含検証。client_secretを監査ログに平文で出さない。ログ出力前にハッシュし、平文は RFC 7591 §3.2.1 と同じ扱い、つまり一度だけ返される one-shot 素材として扱います。- エンドポイントにレート制限を適用する。JWKS の取得や
sector_identifier_uriの検証は実 I/O のコストを伴います。無制限の管理エンドポイントは DoS の増幅手段になります。
5. 仕様外の経路を使う CLI / 管理ツール
パターン 4 ですら過剰な場面、たとえば SRE のバッチ取り込み、リカバリフロー、ローカルのマイグレーションスクリプトでは、ストアに直接接続して RegisterClient を呼ぶバイナリが最もオーバーヘッドの少ない選択肢です。
storage, err := oidcsql.New(db, oidcsql.Postgres())
if err != nil { /* ... */ }
registry := storage.Clients().(store.ClientRegistry)
c := &store.Client{
ID: "support-tool",
RedirectURIs: []string{"https://support.example.com/callback"},
GrantTypes: []string{"authorization_code", "refresh_token"},
ResponseTypes: []string{"code"},
TokenEndpointAuthMethod: op.AuthNone.String(),
PublicClient: true,
Source: store.ClientSourceAdmin,
}
if err := registry.RegisterClient(ctx, c); err != nil { /* ... */ }このパターンはライブラリ側のハンドラをすべてバイパスします。/register の隣に同居しているバリデーションは実行されません。リクエストが HTTP レイヤを一度も通らないためです。したがって、書き込みの前にバイナリ側で同じ形式のルールをミラーする必要があります。application_type ごとの redirect URI 形式、jwks / jwks_uri の同時指定不可、URI 系 field の https 限定、fragment 禁止、ワイルドカードホスト禁止です。監査は運用者の責任です(バイナリ自身のログ、OS の監査サブシステム、または OP と同じシンクへの構造化イベント書き込み)。OP が in-memory キャッシュを保持しているなら、パターン 2 と同じ要領で無効化を計画してください。
本ライブラリが意図的に提供しないもの
/admin/clients等の scope 保護された管理エンドポイント。vendor SaaS 系では同梱されることが多いものの、本ライブラリは同梱しません。このエンドポイントには IETF / OIDF の仕様が存在せず、セキュリティの責任範囲(権限昇格、テナント境界、監査)はアプリケーションごとに異なります。汎用実装を同梱すると、組み込み側がセキュリティ層を十分にカスタマイズしないままになりがちです。software_statement(RFC 7591 §2.3)は受け付けません。指定されたリクエストはinvalid_software_statementで拒否します。federation / 署名付き software_statement の信頼チェーンは現状スコープ外です。- OpenID Federation 1.0 の信頼チェーンは未実装です。
Provider.IntrospectAccessToken(ctx, token)のような、コードから直接呼べる introspection メソッドはありません。組み込み側が自分の管理ハンドラから AT を検証する場合、/introspectを HTTP で呼び、JSON 応答をパースします。JWT 形式の AT に対しては OP の JWKS を使ってインプロセス検証することもできますが、それでは失効の可視性を失います — 誤用のコストが大きい管理操作では/introspect経由が推奨です。/introspectへの HTTP 往復のコストは、誤用が起きた際の被害に比べれば十分に安い保険です。
次に読む
- 動的クライアント登録 — RFC 7591 / 7592 の詳細。パターン 4 と 5 がミラーすべきセキュリティの最低ラインを含みます。
- Public / Internal スコープ — 管理用クライアントが使う scope 語彙の設計。
- 設計判断 —
software_statement拒否などの既定を意図して選んでいる理由。