Skip to content

ユースケース — 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信頼先として設定した 別の主体 が署名した JWTassertion の sub に対応するアカウント

本人確認の根拠が OP の外にあり、署名付きの表明で足りる場合に使います。ユーザをブラウザに誘導できるなら authorization code flow のほうが単純です。入力が自分の OP の発行したトークンなら、トークン交換を使います。

assertion とは

assertion は、「issuer X が、subject Y はこの人だと保証する」という短命の JWT です。OP はユーザをログインさせません。宣言済みの issuer X の鍵で署名を確認し、assertion の宛先がこの OP であること、一度も使われていないことを確かめ、sub をコールバック経由でローカルのアカウントに対応付けます。

grant を有効にする ​

go
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.Issuerassertion の iss と完全一致で比較します。OP 自身の issuer と同じ値は指定できません。
AssertionIssuer.JWKSURI / JWKSissuer の鍵の取得元。https の URL か、インラインの JWK Set のどちらか一方です。URL はクライアントの jwks_uri と同じネットワーク方針で取得されます(プライベートアドレスは op.WithAllowPrivateNetwork(op.OutboundJWKS) で許可)。
AssertionIssuer.AllowedClientsこの issuer の assertion を引き換えてよい client_id。空は不可で、空リストが「全クライアント」の意味になることはありません。
AssertionIssuer.Profileop.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 します。

sh
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/token

assertion の header と claim は次のような形です。

json
{ "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 は拒否します。
issueriss が設定済み issuer のどれかであり、クライアントがその AllowedClients に含まれること。
typ省略、または JWT。
署名issuer の鍵で検証します。
audOP の issuer または token endpoint の URL。配列のどこにあってもかまいません。
sub、expsub は必須。exp は必須で、過去でなく、最大でも 5 分先まで(いずれも 60 秒の leeway あり)。
nbf、iat任意。leeway を超えて未来の値は拒否します。
jti必須で単回使用。リクエストがほかの点で受理できる状態になってから消費します。
scope と resource要求する scope はクライアントの登録 scope の範囲内で、openid は不可。resource はクライアントに登録済みのもの。

エラーコード ​

応答原因
400 unauthorized_clientpublic クライアント、または issuer の AllowedClients に含まれないクライアント。
400 invalid_requestassertion パラメータがない。
400 invalid_grantassertion 自体の不備。未知の issuer、typ 違い、署名不正、aud、exp、nbf、jti の欠落や使用済み。どれかは応答からは分かりません。
400 invalid_scopeクライアントに未登録の scope、または openid を要求した。
400 invalid_targetクライアントに未登録の resource。
400 access_deniedassertion は正当だが、ResolveSubject が op.ErrUnknownAssertionSubject を返した、またはユーザストアに subject がない。
503 server_errorissuer の JWKSURI を取得できなかった。一時的な障害なので再試行してください。
500 server_errorresolver またはユーザストアの障害。

どの段階で失敗したかは、応答ではなく監査イベントの 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 なアイデンティティチェーンのプロファイル。
  • 時計とリプレイ: jti store の共有とサイズ設計。