ユースケース — JWT bearer grant(RFC 7523)
JWT bearer grant(RFC 7523 §2.1)は、OP が信頼する相手の署名付き JWT(assertion)を、access token と引き換える仕組みです。ブラウザは介在しません。クライアントが assertion を /token に POST し、OP が issuer の鍵で検証します。典型的な場面は、パートナーの IdP や社内サービスがすでにユーザを特定していて、そのユーザ宛ての access token を自社の認可サーバから受け取りたい場合です。
どんなときに使うか
似ている 3 つの grant は、subject を誰が保証するかで分かれます。
| grant | クライアントが提示するもの | トークンが表す相手 |
|---|---|---|
client_credentials | クライアント自身の資格情報 | クライアント自身 |
| トークン交換 | この OP が発行したトークン | そのトークンの subject(絞り込みや宛先変更あり) |
| JWT bearer | 信頼先として設定した 別の主体 が署名した JWT | assertion の sub に対応するアカウント |
本人確認の根拠が OP の外にあり、署名付きの表明で足りる場合に使います。ユーザをブラウザに誘導できるなら authorization code flow のほうが単純です。入力が自分の OP の発行したトークンなら、トークン交換を使います。
assertion とは
assertion は、「issuer X が、subject Y はこの人だと保証する」という短命の JWT です。OP はユーザをログインさせません。宣言済みの issuer X の鍵で署名を確認し、assertion の宛先がこの OP であること、一度も使われていないことを確かめ、sub をコールバック経由でローカルのアカウントに対応付けます。
grant を有効にする
import (
"context"
"github.com/libraz/go-oidc-provider/op"
"github.com/libraz/go-oidc-provider/op/grant"
"github.com/libraz/go-oidc-provider/op/storeadapter/inmem"
)
st := inmem.New()
provider, err := op.New(
op.WithIssuer("https://op.example.com"),
op.WithStore(st),
// ResolveSubject が返す subject はすべて user store で確認されるので、
// 明示的に指定する。
op.WithUserStore(st.Users()),
op.WithKeyset(myKeyset),
op.WithCookieKeys(myCookieKey),
op.WithGrants(grant.ClientCredentials),
op.WithJWTBearerGrant(op.JWTBearerGrant{
Issuers: []op.AssertionIssuer{{
Issuer: "https://idp.partner.example",
JWKSURI: "https://idp.partner.example/jwks.json",
AllowedClients: []string{"partner-sync"},
}},
ResolveSubject: func(ctx context.Context, a op.AssertionSubject) (string, error) {
local, ok := lookupLocalAccount(ctx, a.Issuer, a.Subject)
if !ok {
return "", op.ErrUnknownAssertionSubject
}
return local, nil
},
}),
op.WithStaticClients(op.ConfidentialClient{
ID: "partner-sync",
Secret: partnerSecret,
AuthMethod: op.AuthClientSecretBasic,
// クライアント側にも jwt-bearer の URN を登録する。
GrantTypes: []string{"urn:ietf:params:oauth:grant-type:jwt-bearer"},
Scopes: []string{"records:read"},
}),
)op.WithJWTBearerGrant(...) は、オプションの並び順に関係なく grant.JWTBearer を grant 集合に加え、grant_types_supported に URN を載せます。信頼する issuer は discovery には出ません。
| フィールド | 意味 |
|---|---|
JWTBearerGrant.Issuers | 信頼する issuer の閉じた集合。空は不可で、同じ Issuer を 2 つ持てません。 |
JWTBearerGrant.ResolveSubject | 検証済みの op.AssertionSubject(Issuer、Subject、ClientID、Claims)をローカルの subject に対応付けます。必須で、並行に呼ばれます。 |
AssertionIssuer.Issuer | assertion の iss と完全一致で比較します。OP 自身の issuer と同じ値は指定できません。 |
AssertionIssuer.JWKSURI / JWKS | issuer の鍵の取得元。https の URL か、インラインの JWK Set のどちらか一方です。URL はクライアントの jwks_uri と同じネットワーク方針で取得されます(プライベートアドレスは op.WithAllowPrivateNetwork(op.OutboundJWKS) で許可)。 |
AssertionIssuer.AllowedClients | この issuer の assertion を引き換えてよい client_id。空は不可で、空リストが「全クライアント」の意味になることはありません。 |
AssertionIssuer.Profile | op.AssertionProfileJWT(ゼロ値)が通常の RFC 7523 です。もう一方は ID-JAG のページ で扱います。 |
設定ミスは op.New で失敗する
Issuers が空、ResolveSubject が nil、OP 自身と同じ issuer、http の JWKSURI、JWKSURI と JWKS の両指定または両方なし、AllowedClients が空、ユーザストア未設定、op.WithJWTBearerGrant なしで op.WithGrants に grant.JWTBearer を入れた場合は、すべて起動時に拒否されます。op.WithJWTBearerGrant は 1 回だけ指定でき、op.WithCustomGrant で jwt-bearer の URN を再利用することもできません。
アカウントモデルとの接点は ResolveSubject です。assertion は正当でも、provisioning していない相手を指している場合は op.ErrUnknownAssertionSubject を返してください。access_denied で応答されます。それ以外のエラーや空の subject は 500 server_error、ユーザストアに存在しない subject も access_denied です。
引き換えの例
クライアントは自分自身として認証し、assertion を POST します。
curl -s \
-u partner-sync:<secret> \
-d 'grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer' \
-d "assertion=$ASSERTION" \
-d 'scope=records:read' \
https://op.example.com/oidc/tokenassertion の header と claim は次のような形です。
{ "alg": "ES256", "typ": "JWT", "kid": "partner-2026" }
{
"iss": "https://idp.partner.example",
"sub": "u-7731",
"aud": "https://op.example.com",
"iat": 1790000000,
"exp": 1790000240,
"jti": "5d0c7c1e-9b0a-4a63-8f67-2b8f4c9a1d11"
}レスポンスは、ResolveSubject が返したアカウント宛ての bearer access token で、scope は records:read です。scope を省略すると、クライアントの登録 scope から openid を除いたものが使われます。
OP が検証すること
OP は決まった順序で検証します。クライアントがリクエストを直せば解消する問題は、assertion の jti を消費する前にすべて確認するので、不正な呼び出しで正しい assertion が使い捨てになることはありません。
| 検証 | ルール |
|---|---|
| クライアント | 認証済みの confidential クライアント。none は拒否します。 |
| issuer | iss が設定済み issuer のどれかであり、クライアントがその AllowedClients に含まれること。 |
typ | 省略、または JWT。 |
| 署名 | issuer の鍵で検証します。 |
aud | OP の issuer または token endpoint の URL。配列のどこにあってもかまいません。 |
sub、exp | sub は必須。exp は必須で、過去でなく、最大でも 5 分先まで(いずれも 60 秒の leeway あり)。 |
nbf、iat | 任意。leeway を超えて未来の値は拒否します。 |
jti | 必須で単回使用。リクエストがほかの点で受理できる状態になってから消費します。 |
| scope と resource | 要求する scope はクライアントの登録 scope の範囲内で、openid は不可。resource はクライアントに登録済みのもの。 |
エラーコード
| 応答 | 原因 |
|---|---|
400 unauthorized_client | public クライアント、または issuer の AllowedClients に含まれないクライアント。 |
400 invalid_request | assertion パラメータがない。 |
400 invalid_grant | assertion 自体の不備。未知の issuer、typ 違い、署名不正、aud、exp、nbf、jti の欠落や使用済み。どれかは応答からは分かりません。 |
400 invalid_scope | クライアントに未登録の scope、または openid を要求した。 |
400 invalid_target | クライアントに未登録の resource。 |
400 access_denied | assertion は正当だが、ResolveSubject が op.ErrUnknownAssertionSubject を返した、またはユーザストアに subject がない。 |
503 server_error | issuer の JWKSURI を取得できなかった。一時的な障害なので再試行してください。 |
500 server_error | resolver またはユーザストアの障害。 |
どの段階で失敗したかは、応答ではなく監査イベントの reason に記録されます。
発行されるトークン
- refresh token も ID Token も発行しません。 access token が切れたら、クライアントは新しい assertion を用意して引き換えます。使用済みの assertion は拒否されるので、issuer から取り直す必要があります。
- 送信者制約はリクエストに従います。 引き換え時に DPoP proof またはクライアント証明書があれば access token に
cnfが付き、なければ bearer token です。送信者制約 を参照してください。 - grant レコードには紐づきません。
Provider.RevokeSubjectはこのトークンには届きません。次の発行はResolveSubjectとユーザストアの確認で止まりますが、発行済みのトークンは通常の TTL まで有効です。
運用
jti の単回使用は ConsumedJTIStore の substore で行われ、issuer と jti をキーにした専用の名前空間を使います。レプリカ間でこの store を共有してください。 共有しないと、各ノードが同じ assertion を 1 回ずつ受理してしまい、リプレイできる期間が assertion の有効期間まで延びます。in-memory 実装はプロセスごとなので、複数ノードでは共有バックエンドが必要です(noeviction の共有 Redis が定番)。時計とリプレイ を参照してください。
この grant には 2 つの監査イベントがあります。どちらも 監査イベントカタログ にあります。
audit.JWTBearerGranted(jwt_bearer.granted):extrasにissuerとprofile。actor は解決後の subject です。audit.JWTBearerRejected(jwt_bearer.rejected):extras["reason"]が失敗した段階を示します。verifier 側はparse、issuer、typ、signature、claims、idjag、replay。endpoint 側はpublic_client、client_not_allowed、scope、resource、binding、unknown_subject、key_source_unavailableなどです。signatureが増えたらJWKSの古さ、replayはクライアントの再送、key_source_unavailableは issuer の JWKS endpoint を疑ってください。
JWKSURI を使えば、issuer 側の鍵ローテーションに追従する作業は不要です。インラインの JWKS は、ローテーションのたびに再デプロイが必要です。
同じ grant で、ある OP が発行した Identity Assertion JWT Authorization Grant を別の OP で引き換えることもできます。このプロファイルは experimental です。ID-JAG を参照してください。
次に読む
- トークン交換: 入力のトークンが自分の OP の発行物ならこちら。
- カスタム grant: ライブラリが持たない grant 用の URN ルーティング。
- ID-JAG: この grant の上に作られた experimental なアイデンティティチェーンのプロファイル。
- 時計とリプレイ:
jtistore の共有とサイズ設計。