ユースケース — CIBA(Client-Initiated Backchannel Authentication)
CIBA の概念的背景(何で、device flow とどう違い、なぜ binding_message が重要か)は CIBA 入門 を先に読んでください。このページは組み込み手順を扱います。
poll / ping / push 配信モードの違い
CIBA は OP が利用デバイスに「ユーザが承認した」と伝える方法を 3 つ定義しています。poll は利用デバイスが応答が来るまで /token を繰り返しポーリングする形(device-code と同じ)です。ping は OP がクライアント登録済みの webhook に通知を送り、それを受けてクライアントが /token を poll する形です。push は OP が発行済みトークンを直接クライアントの webhook に届ける形です。本ライブラリは poll のみを実装しており、discovery のサポートリストもそれに揃えてあるので、クライアント側で他モードへネゴシエートできません。
auth_req_id とは
/bc-authorize が利用デバイスに返す不透明識別子です。CIBA における device_code 相当で、デバイスが内部に保持し /token への poll ごとに送信します。device-code と異なり、ユーザに見せる別途のコードはありません。ユーザの認証デバイスへ push 通知でプロンプトが直接届くので、利用デバイスは polling 用のハンドルだけ持っていれば十分です。
binding_message とは
利用デバイスが /bc-authorize に渡す短い人間可読な文字列で、OP が認証デバイスのプロンプトに転送します。レジの POS が「Acme Coffee で 800 円を承認、端末 #14」と表示し、ユーザのスマホの承認ダイアログにも同じ文字列が表示されます。これは「目の前の取引と本当に対応するプロンプトか」をユーザが判別する唯一のシグナルです — 無ければ、無関係な CIBA リクエストを発火させた phisher が、漠然とした「サインインを承認しますか?」ダイアログでユーザを欺けます。仕様上 optional でも、運用では必須として扱ってください。
poll mode のみ
本ライブラリは poll 配信を実装します。push / ping 配信モードは v2+ で対応予定です。Discovery は backchannel_token_delivery_modes_supported: ["poll"] のみを広告するため、クライアント側からこれら 2 モードへ交渉することはできません。設計が push / ping を必要とするなら、このリリースの OP は適していません。
CIBA を有効化
import (
"github.com/libraz/go-oidc-provider/op"
"github.com/libraz/go-oidc-provider/op/storeadapter/inmem"
)
provider, err := op.New(
op.WithIssuer("https://op.example.com"),
op.WithStore(inmem.New()), // CIBARequestStore サブストアを同梱
op.WithKeyset(myKeyset),
op.WithCookieKeys(myCookieKey),
op.WithCIBA(
op.WithCIBAHintResolver(myHintResolver),
op.WithCIBAPollInterval(5 * time.Second), // 任意。既定 5 秒
op.WithCIBADefaultExpiresIn(10 * time.Minute), // 任意。既定 10 分
op.WithCIBAMaxExpiresIn(15 * time.Minute), // `requested_expiry` の上限(任意)
op.WithCIBAMaxPollViolations(8), // 任意。既定 5 ストライクを引き上げる
),
op.WithStaticClients(op.ConfidentialClient{
ID: "pos-terminal",
Secret: posSecret,
AuthMethod: op.AuthClientSecretBasic, // FAPI-CIBA では private_key_jwt に
GrantTypes: []string{"urn:openid:params:grant-type:ciba"},
Scopes: []string{"openid", "profile"},
}),
)op.WithCIBA(...) がやること:
/bc-authorizeを設定済 endpoint パスにマウント- CIBA URN(
urn:openid:params:grant-type:ciba)を/tokenに登録 - discovery に
backchannel_authentication_endpoint、backchannel_token_delivery_modes_supported: ["poll"]、backchannel_user_code_parameter_supported: falseを出力。JAR も有効な場合はbackchannel_authentication_request_signing_alg_values_supportedも出力
CIBA サブストア(store.CIBARequestStore)は必須です。in-memory と SQL の両アダプタが同梱しています — SQL アダプタは sqlite / mysql / postgres で oidc_ciba_requests テーブルに永続化します。Redis アダプタはこのサブストアに nil を返すため、Redis のみの構成では composite アダプタで CIBARequests を durable な層(SQL か in-memory)にルーティングしてください。op.WithCIBA(...) 経由でも op.WithGrants(grant.CIBA, ...) 経由でも、op.New は Store.CIBARequests() と HintResolver の両方が組み込まれていることを確認します。どちらが欠けていても構成エラーで起動を拒否します。
HintResolver を実装する
CIBA では、承認以前に どのユーザに push するか を OP が知る必要があります。op.WithCIBAHintResolver(...) は必須で、これ無しで WithCIBA を呼ぶと op.New が失敗します:
login_hint / id_token_hint / login_hint_token の違い
CIBA は利用デバイスがユーザを指名する方法を 3 つ用意しています。login_hint は組み込み側が解釈する自由形式の文字列で、メール、口座番号、ロイヤリティカード等が入ります。id_token_hint は過去発行の ID token で、OP が signature / issuer / audience / 有効期限を検証してからリゾルバに値を渡します。login_hint_token は信頼している上流システム(federation IdP、企業ディレクトリ等)が発行した署名付き JWT で、リゾルバ側で登録済みの鍵を使って署名検証してから sub を読み取ります。OP が inbound リクエストを適切な HintKind に振り分けるので、リゾルバ側は形ごとに分岐を 1 つ書くだけで済みます。
HintResolver とは
/bc-authorize ごとに OP が 1 度呼び出すインターフェースで、「組み込み側が考えるユーザの指名」を安定的な内部 sub に翻訳します。OP はこれを推測できません。ユーザテーブルは組み込み側ごとに異なるからです。Resolve(ctx, kind, value) は subject 文字列を返します(不明なら op.ErrUnknownCIBAUser、一過性の参照失敗なら login_required)。リクエストのホットパス上で動くため、リモートストアへの参照はローカルでキャッシュしてください。
type myHintResolver struct{ /* db handle */ }
func (r *myHintResolver) Resolve(ctx context.Context, kind op.HintKind, value string) (string, error) {
switch kind {
case op.HintLoginHint:
// value = "alice@example.com"、口座番号、ロイヤリティカード等
sub, err := r.lookupBy(ctx, value)
if errors.Is(err, sql.ErrNoRows) {
return "", op.ErrUnknownCIBAUser // → 通信路上の応答: unknown_user_id
}
if err != nil {
return "", err // → 通信路上の応答: login_required
}
return sub, nil
case op.HintIDTokenHint:
// value は過去発行の ID token。OP が signature + iss + aud + exp を
// すでに検証済で Resolve を呼ぶ。sub を引き出す。
return claimsSubject(value), nil
case op.HintLoginHintToken:
// value は信頼している別の上流システムが発行した署名付き JWT。
// 登録済みの鍵で署名を検証し、`sub` claim を読み取る。
return r.verifyLoginHintToken(ctx, value)
}
return "", op.ErrUnknownCIBAUser
}Resolver はリクエストのホットパス上で動く
Resolve は /bc-authorize POST ごとに呼ばれます。バックエンドがリモートならローカルでキャッシュしてください — push 通知ごとにこの呼び出しを待ちます。
ワンオフ / 関数的に使うなら op.HintResolverFunc で関数を HintResolver に変換できます。
認証デバイスのコールバック
OP はユーザのスマホへ push する channel 自体は所有しません。そこは組み込み側の通知サービスとユーザのアプリの協働です。ライブラリが提供する接点はサブストアです。ユーザのアプリが応答してきたら、組み込み側のコールバックハンドラが CIBARequestStore.Approve(または Deny)を op.WithStore に渡したのと同じストア参照 に対して直接呼びます。provider.Store() のようなアクセサは存在しません。OP はストアを再公開せず、組み込み側で参照を保持しておく前提です。
// st は op.WithStore(st) に渡したのと同じストア。組み込み側で
// 参照を保持しておく前提で、provider.Store() のようなアクセサは存在しない。
func handleApproval(w http.ResponseWriter, r *http.Request, st *inmem.Store) {
authReqID := r.FormValue("auth_req_id")
decision := r.FormValue("decision") // "approve" または "deny"
sub := mustExtractSubFromAppSession(r)
switch decision {
case "approve":
// authTime はユーザが authentication device 上で認証した壁時計時刻。
// token endpoint が id_token.auth_time に入れ(ゼロ値は claim を出さない)、
// `RequireAuthTime` を登録したクライアントはこの値で判定する。
if err := st.CIBARequests().Approve(r.Context(), authReqID, sub, time.Now()); err != nil {
http.Error(w, "approve failed", 500)
return
}
case "deny":
if err := st.CIBARequests().Deny(r.Context(), authReqID, "user_denied"); err != nil {
http.Error(w, "deny failed", 500)
return
}
}
w.WriteHeader(204)
}利用デバイスからの次の /token poll が成功(または access_denied を返却)します。
binding_message
/bc-authorize POST のたびに利用デバイスから binding_message を渡します。OP がサブストアレコード経由で転送するので、認証デバイスの push がレジ係の見ている文字列と同じものを描画できます:
curl -s -u pos-terminal:<secret> \
-d 'scope=openid profile' \
-d 'login_hint=alice' \
-d 'binding_message=Acme Coffee 800 円を承認、端末 #14' \
https://op.example.com/oidc/bc-authorizeこれがユーザにとって CIBA phishing への唯一の防衛線です。仕様上 optional ですが、組み込み側の UX では 必須 として扱ってください。
RFC 8707 resource=
利用デバイスは /bc-authorize に resource=<absolute URI> を付けて、発行されるアクセストークンを resource server に固定できます。エンドポイントは /authorize / /token と同じ判定を適用します:
- 値は絶対 URI でなければなりません(RFC 8707 §2)。相対 URI は
400 invalid_targetで拒否されます。 - 正規化後の値(scheme + host を小文字化、末尾
/除去)はクライアントのResourcesallow-list に含まれている必要があります。クライアントに登録されていない resource を要求すると400 invalid_targetで拒否されます。 resource=を複数指定すると400 invalid_targetで拒否されます。このリリースの CIBA 発行パイプラインは単一 audience だけを encode するため、複数 audience を黙って切り捨てる入力は受け付けません。
resource= は登録済みである必要があります
resource= は絶対 URI で、かつクライアントの Resources 許可リストに含まれている必要があります。許可リスト外の値は invalid_target で拒否されます。
CIBA id_token の amr と acr
CIBA フロー終端で発行される id_token は、ACRValues[0](非空のとき)を acr に入れるので、RP は要求された認証コンテキストクラスを参照できます。amr は入りません。CIBA request レコードには、ユーザの認証デバイスが実際に満たした認証手段の signal がまだ無いためです。OIDC Core §2 は acr と amr を別概念として定義しており同義ではありません。
CIBA id_token の amr を読んでいる RP は、実値を提供するサブストア拡張が入るまで、空 / 不在として扱ってください。
FAPI-CIBA プロファイル
op.WithProfile(profile.FAPICIBA) を有効化すると以下の項目が固定されます:
RequiredFeatures=[JAR]—/bc-authorizeリクエストは JWT-Secured(RFC 9101)必須RequiredAnyOf=[[DPoP, MTLS]]— sender constraint 必須。mTLS が明示されていない場合は DPoP が既定選択されるMaxAccessTokenTTL= 10 分- クライアント認証 =
private_key_jwt/tls_client_auth/self_signed_tls_client_auth(FAPI 2.0 セット。client_secret_basicは拒否) RequiresAccessTokenRevocation= true/bc-authorizeの JAR 強制:iss/aud/exp/nbf/iat/jtiをすべて必須、request-object 寿命は 60 分上限(FAPI 2.0 Message Signing §5.6)。FAPI 2.0 Baseline / Message Signing ではjtiは任意のままですが、FAPI-CIBA はより厳格な要件に戻ります。requested_expiry > 600sはハードエラーinvalid_request(FAPI-CIBA-ID1 §5 / FAPI 2.0 §3.1.9 の 10 分上限)。プロファイル無効時の素の CIBA は黙って上限へクランプする挙動のまま/bc-authorizeで発生したあらゆる JAR 失敗(署名不一致、未サポート alg、必須 claim 欠如、request_uri取得失敗、…)は CIBA Core §13 に従って400 invalid_requestにマップされます。素の/authorizeの JAR 経路はより細かいエラー語彙を保持しますが、CIBA は仕様上 back-channel 側に細分化の余地がないため、ここで畳み込みます。
op.WithACRValuesSupported(...) が非空のとき、エンドポイントは要求された acr_values の各エントリを公開済みリストに対して検証します。空リストの場合は従来どおり緩い受理(permissive)のままです。
Polling 応答
device-code grant と同じ形:
| 通信路上の応答 | 意味 |
|---|---|
400 authorization_pending | ユーザがまだ承認していない。交渉済 interval 後に再 poll |
400 slow_down | poll が速すぎた。引き上げられた interval に従う(サーバが永続化) |
400 access_denied | ユーザが拒否、管理者が失効させた、または poll-abuse 上限(WithCIBAMaxPollViolations、既定 5)に達してロックアウト。poll を停止 |
400 expired_token | auth_req_id が寿命を超えた(TTL 経過のみ — RFC 6749 §5.2 / CIBA Core §11)。poll を停止 |
400 invalid_grant | auth_req_id は既に消費済み。grant は消滅しているため、同じハンドルで再試行しないこと |
200 { access_token, ... } | 承認 |
OP は「交渉済み interval が経過する前に poll した」を auth_req_id に対するストライクとして数えます。ストライク数が上限(既定 5)に達すると、その後の poll はすべて 400 access_denied を返し、ciba.poll_abuse.lockout 監査イベントが発火します。op.WithCIBAMaxPollViolations(n uint8) でこの上限を上下できます。プロファイル要件や conformance ハーネスが境界を強く検査するケースで余裕を持たせるために使います。n=0 は本ライブラリの既定にフォールバックし、n=255 は診断ビルド向けに実質的な無効化となります。
/bc-authorize の単一値パラメータ重複
client_id / login_hint / id_token_hint / login_hint_token / binding_message / requested_expiry / acr_values / scope / user_code / client_assertion のいずれかが 2 回以上現れたリクエストは CIBA Core §13 に従い 400 invalid_request で拒否します。RFC 8707 の resource= だけが正当に複数指定可能です。/token、/end_session、/revoke も対応する単一値パラメータに同じルールを適用します。
動かしてみる
(cd examples/32-ciba-pos && go run -tags example .)POS 端末が /bc-authorize に POST し、スタッフのスマホ役の goroutine が CIBARequestStore.Approve を直接呼び、POS が token 発行まで poll します。end-to-end で約 5 秒。ファイル: op.go(OP の組み立て + HintResolver)、rp.go(POS 側 polling)、device.go(スマホ承認シミュレーション)。
続きはこちら
- CIBA 入門 — 概念的背景
- Device Code の組み込み — ユーザが見ている利用面に画面とコードがある場合
- FAPI 2.0 Baseline — FAPI-CIBA が継承する親プロファイル