Skip to content

使い方 — カスタム grant

標準カタログにない grant_type が必要なシナリオがあります。ベンダ固有の service-token-exchange URN、内部の「外部 assertion から token を発行」パス、レガシ AS から移行中の暫定 shim など。op.WithCustomGrant(...) はディスパッチャを fork せずに、組み込み側が定義した URN を /token 経由でルーティングするための差し込み口です。

grant_type URN とは

grant_type/token のフォームパラメータで、どの発行パスを実行するか(authorization_codeclient_credentialsrefresh_token など)を選びます。well-known な値は短い文字列ですが、独自定義のものは urn:<vendor>:<your-name> 形式の URN を使い、別ベンダと名前が衝突しないようにします。urn:ietf:params:oauth:grant-type:device_code は IETF が認めた例、urn:example:libraz:service-token-exchange は組み込み側が自前で発行する例です。

発行パイプラインとは

標準 grant がディスパッチャに識別された後で共通して通る処理パスのことです。クライアントの許可リストとの scope の積集合、登録 resource との audience の積集合、グローバル上限による TTL の頭打ち、送信者制約付きトークンへの cnf の押印、リフレッシュトークンの親子関係の追跡などが含まれます。custom grant は scope / audience / TTL / cnf の部分を共有します。IssueRefreshToken を立てることで、OP にリフレッシュトークン発行も依頼できます。ただしハンドラがリフレッシュトークンの値を直接渡すことはありません。

標準 grant で済むなら標準 grant を

custom grant は標準カタログ(authorization_codeclient_credentialsrefresh_tokenurn:ietf:params:oauth:grant-type:device_codeurn:ietf:params:oauth:grant-type:token-exchange、CIBA)が本当に合わないケース用です。標準 grant が共有する発行パイプラインを通らない部分があるため、scope / audience / バインディングを正しく扱うのはハンドラの責任です。標準 grant の方が悪い設計を強制してくる場合に限り選んでください。

ハンドラを登録する

go
import (
  "github.com/libraz/go-oidc-provider/op"
  "github.com/libraz/go-oidc-provider/op/grant"
  "github.com/libraz/go-oidc-provider/op/storeadapter/inmem"
)

provider, err := op.New(
  op.WithIssuer("https://op.example.com"),
  op.WithStore(inmem.New()),
  op.WithKeyset(myKeyset),
  op.WithCookieKeys(myCookieKey),

  // custom grant は拡張 dispatcher 経路。組み込み grant を明示し、
  // 下の refresh 例で使う refresh_token も有効にします。
  // browser grant は有効にしません。
  op.WithGrants(grant.ClientCredentials, grant.RefreshToken),

  op.WithCustomGrant(&serviceTokenHandler{}),
  // op.WithCustomGrant は複数ハンドラを登録するため繰り返し呼べる
)

構築時エラー:

エラー状況
op.ErrCustomGrantNilハンドラが nil
op.ErrCustomGrantNameEmptyName()""
op.ErrCustomGrantBuiltinCollisionName() が built-in URN と衝突
op.ErrCustomGrantDuplicate同名ハンドラが登録済み
op.ErrCustomGrantSecretLikeExemptParamPolicy.DupesAllowed がセキュリティセンシティブパラメータを含む

Name() は OP が実装するすべての grant wire(authorization_coderefresh_tokenclient_credentials、Device Code / CIBA の URN、RFC 8693 token-exchange の URN)と byte 単位で比較されます。custom handler が built-in dispatcher を置き換えることはできないため、配備側が所有する URN を選んでください。

ハンドラインターフェース

go
type serviceTokenHandler struct{ /* deps */ }

func (h *serviceTokenHandler) Name() string {
    return "urn:example:libraz:service-token-exchange"
}

func (h *serviceTokenHandler) ParamPolicy() op.ParamPolicy {
    return op.ParamPolicy{
        Allowed:      []string{"target_service", "act_as"},
        DupesAllowed: nil,
    }
}

func (h *serviceTokenHandler) Handle(ctx context.Context, req op.CustomGrantRequest) (op.CustomGrantResponse, error) {
    target := req.Form["target_service"][0]
    if !h.allowed(req.Client.ID, target) {
        return op.CustomGrantResponse{}, &op.Error{
            Code:        "invalid_target",
            Description: "client is not allowed to mint tokens for " + target,
        }
    }

    return op.CustomGrantResponse{
        BoundAccessToken: &op.BoundAccessToken{
            Subject:  op.Subject(req.Client.ID),       // service token: sub = client_id
            Audience: []string{target},
            TTL:      5 * time.Minute,
            ExtraClaims: map[string]any{
                "service_chain": h.chainFor(req.Client.ID, target),
            },
        },
        Scope: []string{"service.invoke"},
    }, nil
}

req.Subject は常に nilreq.AuthTime は常に zero time です。token request が認証するのは end-user ではなく client であり、OP は custom-grant のパラメータを解釈して subject や認証 ceremony を作りません。subject が必要なら handler が解決し、CustomGrantResponse.Subject(または BoundAccessToken.Subject)で返してください。認証時刻を持っている場合は CustomGrantResponse.AuthTime に返します。

発行の 2 形態

ハンドラは OP 署名BoundAccessToken)と ハンドラ署名AccessToken)を選択します — 排他です。

カスタム grant が返せる 2 つの形
カスタム grant のハンドラは BoundAccessToken を返すこともでき、その場合は署名鍵・標準 claim・cnf・ID トークン・リフレッシュトークンを OP が管理します。署名済みのアクセストークンを返すこともでき、その場合は cnf と失効の連携がハンドラ側の責任になります。CustomGrantHandlerHandle(ctx, req)BoundAccessToken を返す通常はこちらOP が署名鍵を選び、JWT を発行する標準 claim · cnf · TTL の上限ID トークンとリフレッシュトークンも OP 管理のまま失効まわりに自前の実装は要らないAccessToken を返す明確な理由がある場合のみハンドラが署名済みの値を返す外部 KMS や独自の opaque backend 向けcnf と introspection の連携は組み込み側の責任失効をどこまで届かせるかも同様
右の形は、署名鍵が本当に OP の手元に無い場合のためにあります。それ以外の理由で選ぶと、4 つの責務がライブラリからハンドラ側へ移り、しかもそのどれもが気付きにくい形で誤りやすい部分です。

この 2 形態は排他です。ハンドラが両方を返した場合、OP は server_error を返します。発行の責任は必ずどちらか一方に固定されます。

OP 署名 vs ハンドラ署名 — どちらを選ぶか

OP 署名(BoundAccessToken)は、OP が登録済みの keyset から鍵を選んで JWT に署名する形です。さらに、リクエストの検証済み DPoP / mTLS 証明から cnf を押印し、予約 claim フィルタのもとで追加 claim をマージします。ハンドラ署名(AccessToken)は、外部 KMS / HSM で生成済みのトークン(または独自 introspection backend が解釈する opaque token)を持ち込み、OP にそのまま返させる形です。cnf を含むすべての責任はハンドラが負います。明確な理由がない限り OP 署名を選んでください。

BoundAccessToken — OP が署名 + バインド

ハンドラが別経路の署名鍵を持たない場合は BoundAccessToken を返します。OP が:

  • アクティブな署名鍵で JWT-shape アクセストークンを署名
  • iss / sub / aud / exp / iat / jti / scope / client_id を埋める
  • リクエストが検証済みの証明を提示していれば cnf.jkt(DPoP)または cnf.x5t#S256(mTLS)を自動で押印。ハンドラがバインディングを自前で通す必要なし
  • ExtraClaims をマージ(標準セットとの衝突は server_error に集約 — ハンドラのバグが監査ログに浮かび上がるように)

これが多くの組み込み側にとって正しい既定です。その結果、OP は FAPI 2.0 §3.1.4 のバインディング契約を追加コストなしで強制します。

AccessToken — ハンドラが署名

ハンドラが外部 KMS / HSM 鍵で署名する場合、または独自 introspection backend を持つ opaque token を発行する場合は、CustomGrantResponse.AccessToken に値を直接書き込みます。OP はその値をそのまま返します。

ハンドラ署名 = バインディングは自分で

AccessToken の場合、OP は cnf押印しませんreq.DPoP != nil または req.MTLSCert != nil で JWT を発行するなら、cnf.jkt / cnf.x5t#S256 を claim に 自分で 埋めてください。Opaque-format のハンドラは独自 introspection backend でバインディングを露出する責任があります — OP はハンドラ提供 token のシャドウ行を保持しません。

ParamPolicy

ParamPolicyreq.Form 経由でハンドラに渡すパラメータを宣言します:

ParamPolicy とは

/token のフォームパーサは、認識しないパラメータを拒否することで、行儀の悪いクライアントが余計な入力をハンドラへ持ち込むのを防いでいます。ParamPolicy は custom grant がパーサに「この名前は私のものなので通してほしい」と伝える仕組みで、Allowed はハンドラが読むフォームキーの一覧、DupesAllowed は重複値を許す部分集合(既定は単一値のみ)です。セキュリティ上敏感な名前(client_secretcode_verifier 等)はどちらにも入れられません — 誤設定で credential 受け口が広がらないよう、構築時点で OP が拒否します。

go
op.ParamPolicy{
    // 共有パラメータ(grant_type、client_id、client_secret、scope、...)以外で
    // 許可する名前。未知の名前は invalid_request。
    Allowed: []string{"target_service", "act_as"},

    // Allowed のうち重複値を許す subset。既定は重複なし。
    // OP は名前ごとに CustomGrantDupCap(32)の hard cap を強制。
    DupesAllowed: []string{"target_service"},
}

セキュリティ上敏感な名前(grant_type / client_id / client_secret / code / code_verifier / refresh_token / subject_token / actor_token / password / client_assertion / client_assertion_type)を DupesAllowed に入れると、構築時に op.ErrCustomGrantSecretLikeExempt が出ます。誤設定で credential の取り扱いを劣化させないためです。

ハンドラの前後で OP が強制すること

OP が Handle の前後に適用する最低ライン:

  • scope の積集合CustomGrantResponse.Scope ∩ client の許可 scope。集合外は invalid_scope
  • audience の積集合Audience の各エントリが client に登録されている resource に一致している必要あり。未知のエントリは invalid_target
  • TTL 上限AccessTokenTTL(または BoundAccessToken.TTL)はグローバルなアクセストークン上限で切り詰められる(超過時は監査警告、負値は拒否)
  • openid scope の自動 id_tokenScopeopenid を含み IDToken が空のとき、OP が Subject + AuthTime + ExtraClaims から id_token を署名(reserved claim filter 適用)

リフレッシュトークン

custom grant は、OP 管理のリフレッシュトークン発行を明示的に有効化できます:

go
return op.CustomGrantResponse{
    BoundAccessToken: &op.BoundAccessToken{ /* ... */ },
    Scope:             []string{"service.invoke", "offline_access"},
    IssueRefreshToken: true,
}, nil

リフレッシュトークンの資格情報は OP が所有します。OP は:

  • 値を生成する
  • RefreshTokenStore に永続化する
  • アクセストークンと同じ grant 識別子を共有させる
  • 同じ DPoP / mTLS 証明にバインドする

そのため、発行されたリフレッシュトークンは通常のローテーション、再利用時の連鎖失効(RFC 9700 §2.2.2)、grant 失効の仕組みに乗ります。

IssueRefreshToken が true の場合、次の 5 つの gate のいずれかに失敗すると、アクセストークン応答は成功したままリフレッシュトークンだけを落とします:

  • provider が refresh_token grant を提供している(WithGrantsgrant.RefreshToken が必要)
  • client が refresh_token grant に登録されている
  • response の subject が空でない
  • response の scope が空でない
  • response の audience が 1 件以下である

落とすたびに理由(provider_grant_disabledclient_not_registeredempty_subjectempty_scopemultiple_resource_audience)付きの custom_grant.refresh_dropped を発火します。リフレッシュトークンを落としても、それ自体でアクセストークンや、他の発行条件を満たす ID Token が失敗することはありません。したがって scope が空でも、無効になるのはリフレッシュトークンだけで、正しい応答は拒否されません。

リフレッシュトークンの親子関係とは

OP はリフレッシュトークンごとに親を記録するため、ローテーションはチェーン(A → B → C)を作ります。そのうちの 1 本が再利用された場合(RFC 9700 §2.2.2)、OP は子孫すべてを一括で失効させられます。IssueRefreshToken は custom grant をこの OP 管理のチェーンに入れるためのフラグです。RFC 6749 §6 ではリフレッシュトークンは authorization server が発行する資格情報なので、ハンドラが値を直接渡す形にはしていません。

OP が拒否すること

  • ハンドラが指定するリフレッシュトークン値。OP に発行させたい場合は IssueRefreshToken: true を使います
  • AccessTokenBoundAccessToken の同時設定。排他。両方設定すると server_error
  • ExtraClaims の reserved claim 衝突iss / sub / aud / iat / exp / auth_time / nonce / acr / amr / azp / at_hash / c_hash / sid(BoundAccessToken では act / cnf も)は、TokenExchangePolicy.ExtraClaims では黙って破棄されます(ポリシー側に上書きさせないため)。一方 CustomGrantResponse.ExtraClaims では server_error(ハンドラの不具合を監査ログに浮かび上がらせるため)になります

クライアントに帰属できる既知の dispatcher 失敗は 4xx の OAuth エラーになります。handler の panic を recover した場合、response 形態の衝突、reserved claim の衝突、OP 管理の token 構築 / 永続化の失敗など、クライアントに帰属できない dispatcher または OP 内部の障害は invalid_grant ではなく 500 server_error になります。クライアントが認証時刻を要求し、custom response が openid を要求しているのに AuthTime が zero の場合も、自動 ID Token 構築に失敗して 500 server_error になります。

動かしてみる

examples/30-custom-grant:

sh
(cd examples/30-custom-grant && GOWORK=off go run -tags example .)

組み込み側が urn:example:libraz:service-token-exchange を定義し、OP が op.WithCustomGrant 経由でルーティングします。ハンドラが BoundAccessToken を返すと、ディスパッチャはリクエストの DPoP / mTLS confirmation に紐付いた JWT アクセストークンを発行します。ファイル: op.go(OP の組み立て + ハンドラ)、client.go(client 側)、probe.go(self-verify)。

続きはこちら

  • Token Exchange の組み込み — ライブラリ内蔵の custom-grant の親類。dispatch の形は同じですが、OP が把握するポリシーのセマンティクス(act チェーン、cnf の再バインドなど)が追加で乗ります
  • Sender constraintcnf が何で、なぜ BoundAccessToken が代わりに押印してくれるのが重要なのか