Skip to content

Use case — Grant management ​

A grant is the OP's record of what a user consented to for one client: the scopes and, with RAR, the authorization_details. Ordinarily the OP keeps a single grant per user and client and quietly amends it on each new authorization, and the client never names it. When a grant is long-lived (a recurring payment mandate, a standing data-access consent), the client needs to name it, add to it later, read back what it holds, and tear it down. The OAuth 2.0 Grant Management draft gives each grant a stable grant_id and five actions over it.

op.WithGrantManagement honours the grant_management_action / grant_id authorization parameters, mounts the grant management endpoint (query / revoke), stamps grant_id onto the token response, and advertises the feature in discovery.

Use it when clients must manage a consent-like grant after the first authorization, especially when the grant can grow or be revoked independently of one browser session. Skip it for short-lived sign-in flows: authorization codes, refresh-token rotation, and ordinary token revocation are simpler when the client never needs to name a grant.

What sets a grant, and what reaches its tokens
Create, replace and merge travel on the authorization request and decide the grant's scope and authorization details. Query and revoke are operations on the grant management endpoint. Of all five, only revoke reaches the tokens bound to the grant.on the authorization requeston the grant endpointcreateno grant_id; the OP mints onereplacescope := this requestmergescope := existing + newThe grantgrant_id = g-9f3c…scope + details, one recordTokens bound to itaccess tokens (JWT, opaque)refresh tokensGET …/{grant_id}reads scope and detailschanges nothingDELETE …/{grant_id}204 · record deletedevery bound token goes too
Only the delete arrow reaches the tokens. Changing a grant's scope does not change tokens already issued under it; the new scope applies from the next issuance. A replace that drops an authorization_details element withdraws it from opaque access tokens and refresh chains; a JWT already issued keeps the claim until it expires.

Only revoke reaches the bottom box. create, replace, and merge change what the grant holds; query reads it back; revoke deletes the grant record and the tokens hanging off it.

Experimental — tracks an IETF draft

Grant Management is an IETF draft, not a published RFC. The op.GrantManagementAction enum and op.WithGrantManagement are marked Experimental: in godoc: a draft update may make a wire-incompatible change.

The five actions
  • create — start a brand-new grant. A grant_id MUST NOT accompany it; the OP mints one and returns it. Each create makes a separate grant, even for the same user and client.
  • replace — overwrite the referenced grant's scope and authorization_details with the new request's. Requires grant_id.
  • merge — union the referenced grant's scope and authorization_details with the new request's. Requires grant_id.
  • query — read the grant. GET {grant_management_endpoint}/{grant_id}.
  • revoke — delete the grant. DELETE {grant_management_endpoint}/{grant_id}.

create / replace / merge ride the authorization request. query / revoke are operations on the grant management endpoint.

Wiring ​

go
import "github.com/libraz/go-oidc-provider/op"

op.New(
  /* required options */
  op.WithGrantManagement(op.GrantManagement{
    Actions: []op.GrantManagementAction{
      op.GrantActionCreate, op.GrantActionReplace, op.GrantActionMerge,
      op.GrantActionQuery, op.GrantActionRevoke,
    },
    ActionRequired: false,
  }),
)

Actions is the closed set of actions the OP accepts; it is advertised as grant_management_actions_supported, and the endpoint refuses any operation outside it. With ActionRequired set to true, an authorization request that omits grant_management_action is rejected (discovery advertises grant_management_action_required: true). At least one action is required and each must be one of the five values; a duplicate or unknown action is rejected at op.New, and so is a second WithGrantManagement call.

The discovery document then advertises:

json
{
  "grant_management_endpoint": "https://op.example.com/oidc/grant_management",
  "grant_management_actions_supported": ["create", "replace", "merge", "query", "revoke"]
}

The endpoint path defaults to /grant_management under your mount prefix; override it with op.WithEndpoints(op.Endpoints{GrantManagement: "..."}).

Creating and growing a grant ​

The client passes grant_management_action (and, for replace / merge, the grant_id) on the authorization request:

sh
# Create — no grant_id allowed
curl -G --data-urlencode 'grant_management_action=create' \
  --data-urlencode 'response_type=code' \
  --data-urlencode 'client_id=demo' \
  --data-urlencode 'redirect_uri=https://rp.example.com/callback' \
  --data-urlencode 'scope=openid profile' \
  https://op.example.com/oidc/auth

The same parameters are validated on a PAR push. A create or replace / merge request always goes through an explicit consent screen: the first-party consent skip and a cached grant never cover it. After the code exchange, the token response carries the grant's id:

json
{
  "access_token": "...",
  "token_type": "Bearer",
  "grant_id": "g-9f3c..."
}

To change that grant later, the client re-authorizes with grant_management_action=replace (overwrite the scope / authorization_details) or merge (union them), passing the grant_id it received. A grant_id that does not belong to the signed-in user and this client is refused with invalid_grant.

Actiongrant_idEffect on scope / authorization_details
createmust be absentsets them to the new request's
replacerequiredoverwrites with the new request's
mergerequiredunions the existing with the new request's

Querying a grant ​

GET the endpoint with the grant id appended, authenticating as the owning client. Only confidential clients may call the endpoint; a public client gets 401 invalid_client, because a grant_id in a URL path is not a secret (it reaches proxy logs and browser history). The OP enforces ownership, so a client cannot read another client's grant, and returns the grant's current scope and authorization_details:

sh
curl -u demo:demo-secret https://op.example.com/oidc/grant_management/g-9f3c...
json
{
  "scopes": [{"scope": "openid profile"}],
  "authorization_details": [{"type": "payment_initiation", "actions": ["initiate"]}]
}

A member is omitted when the grant holds nothing for it.

Revoking a grant ​

DELETE tears down only the named grant_id, and everything bound to it: the grant's JWT access tokens (by tombstone, or by the registry, depending on the revocation strategy), its opaque access tokens and refresh tokens, and finally the grant record, so a later query reports it gone. Other grants held by the same subject and client are not touched. To revoke every grant of a subject or client, enumerate and delete those grant_ids yourself.

OutcomeResponse
Revoked204 No Content, and a grant_management.revoked audit event for the grant. A delete that races with another successful deletion also returns 204.
Grant does not exist, or belongs to another client404 invalid_request, with no cascade attempted.
The grant store cannot say whether the grant exists500 server_error, with no cascade. Retry.
A cascade step fails500 server_error and a grant_management.revoke_failed audit event. The grant record is kept, so it stays queryable and the delete can be retried.
Public client401 invalid_client
sh
curl -u demo:demo-secret -X DELETE https://op.example.com/oidc/grant_management/g-9f3c...

If an operation is not in the advertised grant_management_actions_supported set, the endpoint answers 405 Method Not Allowed with an Allow header naming only the enabled operations — so a client that registered only query will not see DELETE accepted.