Use case — Custom Grant
Some scenarios need a grant_type the standard catalog does not cover: a vendor-specific service-token-exchange URN, an internal "issue token from external assertion" path, a transitional shim while migrating off a legacy AS. op.WithCustomGrant(...) is the seam that lets the OP route an embedder-defined URN through /token without having to fork the dispatcher.
grant_type URN — what's that?
A grant_type is the /token form parameter that selects which issuance path runs (authorization_code, client_credentials, refresh_token, etc.). The well-known values are short strings; everything custom uses a URN of the form urn:<vendor>:<your-name> so two vendors don't collide on the same name. urn:ietf:params:oauth:grant-type:device_code is the IETF-blessed example; urn:example:libraz:service-token-exchange is what an embedder would mint for itself.
Issuance pipeline — what's that?
The shared code path that the standard grants run through after the dispatcher has identified them: scope intersection against the client's allow-list, audience intersection against registered resources, TTL clamp against the global ceiling, cnf stamping for sender-bound tokens, and refresh-token lineage tracking. Custom grants share the scope / audience / TTL / cnf parts. They can also ask the OP to issue a refresh token by setting IssueRefreshToken; the handler still never supplies the refresh-token value itself.
Use the standard grants when you can
Custom grants exist for the cases where the standard catalog (authorization_code, client_credentials, refresh_token, urn:ietf:params:oauth:grant-type:device_code, urn:ietf:params:oauth:grant-type:token-exchange, CIBA) genuinely does not fit. They bypass the issuance pipeline that the standard grants share — your handler is responsible for getting scope, audience, and binding right. Pick a custom grant only when the standard ones force a worse design.
Registering a handler
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),
// The custom grant is an extension dispatcher path. Select built-in
// grants explicitly; refresh_token is required by the refresh example
// below, while browser grants remain disabled.
op.WithGrants(grant.ClientCredentials, grant.RefreshToken),
op.WithCustomGrant(&serviceTokenHandler{}),
// op.WithCustomGrant can be called repeatedly to register multiple handlers.
)Construction-time errors:
| Error | When |
|---|---|
op.ErrCustomGrantNil | handler is nil |
op.ErrCustomGrantNameEmpty | Name() returned "" |
op.ErrCustomGrantBuiltinCollision | Name() collides with a built-in URN |
op.ErrCustomGrantDuplicate | a handler with the same Name was already registered |
op.ErrCustomGrantSecretLikeExempt | ParamPolicy.DupesAllowed named a security-sensitive parameter |
Name() is compared byte-for-byte with every grant wire the OP implements: authorization_code, refresh_token, client_credentials, the Device Code and CIBA URNs, and the RFC 8693 token-exchange URN. A custom handler cannot replace one of those built-in dispatchers; choose a deployment-owned URN instead.
The handler interface
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 is always nil and req.AuthTime is always the zero time. A token request authenticates the client, not an end user, and the OP does not interpret custom-grant parameters to invent a subject or authentication ceremony. Resolve any subject in the handler and report it through CustomGrantResponse.Subject (or BoundAccessToken.Subject); report its authentication time through CustomGrantResponse.AuthTime when the handler has one.
Two issuance shapes
The handler chooses between OP-signed (BoundAccessToken) and handler-signed (AccessToken) — they are mutually exclusive.
The two shapes are mutually exclusive. A handler that returns both yields server_error, because exactly one party owns issuance.
OP-signed vs handler-signed — what's the trade-off?
OP-signed (BoundAccessToken) means the OP picks a key from its registered keyset and signs the JWT for you. It also stamps cnf from the verified DPoP / mTLS proof on the request, and merges your extra claims under the reserved-claim filter. Handler-signed (AccessToken) means you bring an already-formed token — typically from an external KMS / HSM, or an opaque token your introspection backend understands — and the OP echoes it verbatim. You own everything from there, including cnf if the token needs to be sender-bound. Pick OP-signed unless you have a hard reason not to.
BoundAccessToken — OP signs and binds
When the handler does not have an out-of-band signing key, hand back a BoundAccessToken. The OP:
- Signs a JWT-shape access token with its active signing key.
- Fills
iss / sub / aud / exp / iat / jti / scope / client_id. - Stamps
cnf.jkt(DPoP) orcnf.x5t#S256(mTLS) automatically when the request presented a verified proof — the handler does not need to thread the binding through itself. - Merges
ExtraClaims(collisions with the standard set yieldserver_errorso the bug surfaces in audit).
This is the right default for most embedders. As a result, the OP enforces FAPI 2.0 §3.1.4's binding contract for free.
AccessToken — handler signs
When the handler signs with an external KMS / HSM key, or mints an opaque token backed by its own introspection backend, write the value into CustomGrantResponse.AccessToken directly. The OP echoes it verbatim.
Handler-signed = you own the binding
With AccessToken the OP does not stamp cnf for you. If req.DPoP != nil or req.MTLSCert != nil and you mint a JWT, you must embed cnf.jkt / cnf.x5t#S256 in the claims. Opaque-format handlers must surface the binding through their own introspection backend — the OP does not maintain a shadow row for handler-supplied tokens.
ParamPolicy
The ParamPolicy declares what the OP exposes in req.Form:
ParamPolicy — what's that?
The /token form parser rejects parameters it does not recognise so a misbehaving client cannot smuggle extra inputs past the handler. ParamPolicy is how a custom grant tells the parser "these names are mine, please pass them through" — Allowed lists the form keys the handler reads, DupesAllowed is the subset where the parser permits repeated values (default = single-value only). Security-sensitive names (client_secret, code_verifier, etc.) cannot appear in either list — the OP refuses to construct so a misconfigured handler cannot widen the credential surface.
op.ParamPolicy{
// Names allowed beyond the shared parameters (grant_type, client_id,
// client_secret, scope, ...). Unknown names yield invalid_request.
Allowed: []string{"target_service", "act_as"},
// Subset of Allowed that admits repeated values. Default = no duplicates.
// The OP enforces a hard cap of CustomGrantDupCap (32) per name.
DupesAllowed: []string{"target_service"},
}Security-sensitive parameter names (grant_type / client_id / client_secret / code / code_verifier / refresh_token / subject_token / actor_token / password / client_assertion / client_assertion_type) cannot be in DupesAllowed — listing them yields op.ErrCustomGrantSecretLikeExempt at construction time so a misconfigured handler cannot downgrade the credential surface.
What the OP enforces around your handler
These are floors the OP applies before / after Handle:
- Scope intersection —
CustomGrantResponse.Scope∩ client's allowed scopes. Out-of-set entries yieldinvalid_scope. - Audience intersection — each
Audienceentry must match a resource registered for the client. Unknown entries yieldinvalid_target. - TTL cap —
AccessTokenTTL(orBoundAccessToken.TTL) is truncated to the global access-token ceiling with an audit warning if exceeded; negative is rejected. openidscope auto-id_token — whenScopecontainsopenidandIDTokenis empty, the OP signs a fresh id_token fromSubject+AuthTime+ExtraClaims(reserved-claim filter applies).
Refresh tokens
Custom grants can opt into OP-managed refresh-token issuance:
return op.CustomGrantResponse{
BoundAccessToken: &op.BoundAccessToken{ /* ... */ },
Scope: []string{"service.invoke", "offline_access"},
IssueRefreshToken: true,
}, nilThe OP owns the refresh-token credential:
- generates the value
- persists it through
RefreshTokenStore - shares the access token's grant identity
- binds it to the same DPoP / mTLS proof
That means the issued refresh token uses the normal rotation, replay cascade (RFC 9700 §2.2.2), and revocation machinery.
When IssueRefreshToken is true, the refresh token is dropped (while the access-token response still succeeds) if any of these five gates fails:
- The provider must serve the
refresh_tokengrant (grant.RefreshTokeninWithGrants). - The client must be registered for the
refresh_tokengrant. - The response must have a non-empty subject.
- The response scope must be non-empty.
- The response must contain at most one audience.
Each drop emits custom_grant.refresh_dropped with the policy reason (provider_grant_disabled, client_not_registered, empty_subject, empty_scope, or multiple_resource_audience). Dropping the refresh token does not by itself fail the access token or an otherwise eligible ID token; an empty scope therefore suppresses only the refresh token and does not reject an otherwise valid response.
Refresh-token lineage — what's that?
The OP records each refresh token's parent so a rotation produces a chain (A → B → C); when one of those tokens is replayed (RFC 9700 §2.2.2), the OP can revoke every descendant in one shot. IssueRefreshToken keeps custom grants inside that OP-owned chain. A handler cannot provide a refresh-token value directly because RFC 6749 §6 treats the refresh token as an authorization-server-issued credential.
What the OP refuses
- Handler-supplied refresh-token values. Use
IssueRefreshToken: truewhen the OP should mint one. - Both
AccessTokenandBoundAccessToken. Mutually exclusive — setting both yieldsserver_error. - Reserved-claim collisions in
ExtraClaims.iss / sub / aud / iat / exp / auth_time / nonce / acr / amr / azp / at_hash / c_hash / sid(andact/cnfforBoundAccessToken) are dropped silently forTokenExchangePolicy.ExtraClaims(so the policy cannot rewrite them) but yieldserver_errorforCustomGrantResponse.ExtraClaims(so handler bugs surface in the audit record).
Known client-attributable dispatch failures use a 4xx OAuth error. A dispatcher failure, or any OP-internal failure the OP cannot attribute to the client — including a recovered handler panic, a response-shape conflict, a reserved-claim collision, or an OP-owned token construction / storage failure — returns 500 server_error, never invalid_grant. If a client requires authentication time and the custom response requests openid without a non-zero AuthTime, automatic ID-token construction also returns 500 server_error.
See it run
(cd examples/30-custom-grant && GOWORK=off go run -tags example .)The embedder defines urn:example:libraz:service-token-exchange, the OP routes it via op.WithCustomGrant, and the handler returns a BoundAccessToken so the dispatcher mints a JWT access token bound to the request's DPoP / mTLS confirmation. Files: op.go (OP wiring + handler), client.go (client side), probe.go (self-verify).
Read next
- Token Exchange wiring — the in-tree custom-grant cousin; same dispatch shape but with policy semantics the OP knows about (act chain, cnf rebinding).
- Sender constraint — what
cnfdoes and whyBoundAccessTokenstamping it for you matters.