Skip to main content
Version: 1.0

Getting tokens

All token requests go to:

POST https://pbac.example.com/token
Content-Type: application/x-www-form-urlencoded

Confidential clients authenticate via:

  • Basic auth: Authorization: Basic base64(client_id:client_secret)
  • POST body: client_id=...&client_secret=...
  • Client assertion (private_key_jwt): client_id=...&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer&client_assertion=<signed-jwt> — for clients registered with token_endpoint_auth_method=private_key_jwt. The JWT must include iss, sub (= client_id), aud (issuer or token endpoint URL), and exp.

Client credentials grant

Machine-to-machine. Confidential clients only. No user context.

curl -X POST https://pbac.example.com/token \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d "grant_type=client_credentials" \
-d "scope=read" \
-d "resource=https://api.example.com/fhir"

With resource_type hint (when the resource indicator isn't registered in the DB):

curl -X POST https://pbac.example.com/token \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d "grant_type=client_credentials" \
-d "scope=read" \
-d "resource=https://api.example.com/fhir" \
-d "resource_type=urn:example:Patient"

With RAR (RFC 9396 Rich Authorization Requests) — mutually exclusive with resource+scope:

curl -X POST https://pbac.example.com/token \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d "grant_type=client_credentials" \
-d 'authorization_details=[{"type":"urn:example:Patient","actions":["read"],"locations":["https://api.example.com/fhir"]}]'

Successful response:

{
"access_token": "eyJhb...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "read"
}

Authorization code grant

User-interactive. Requires a registered IdP.

Step 1: Redirect user to authorization endpoint

GET https://pbac.example.com/authorize?
response_type=code
&client_id=<client_id>
&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback
&scope=openid+profile+read
&resource=https%3A%2F%2Fapi.example.com%2Ffhir
&state=<random-state>
&code_challenge=<S256-challenge>
&code_challenge_method=S256

The AS selects an IdP based on user.idp_selection policy data and redirects the user to it.

Step 2: Exchange the authorization code

curl -X POST https://pbac.example.com/token \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d "grant_type=authorization_code" \
-d "code=$AUTH_CODE" \
-d "redirect_uri=https://app.example.com/callback" \
-d "code_verifier=$PKCE_VERIFIER"

Successful response:

{
"access_token": "eyJhb...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "def50200...",
"id_token": "eyJhb...",
"scope": "openid profile read"
}

Refresh token grant

Exchange a refresh token for a new access token. Requires the client to have refresh_token in its grant_types. Confidential clients only.

curl -X POST https://pbac.example.com/token \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d "grant_type=refresh_token" \
-d "refresh_token=$REFRESH_TOKEN" \
-d "scope=openid+read" \
-d "resource=https://api.example.com/fhir"

The refresh request also runs OPA policy. If the subject's entitlements have changed since the original token, the new token reflects the updated policy.


Token exchange (RFC 8693)

Exchange a token (e.g. for a different audience, delegation, or impersonation). Useful for service-to-service calls on behalf of a user.

curl -X POST https://pbac.example.com/token \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
-d "subject_token=$ACCESS_TOKEN" \
-d "subject_token_type=urn:ietf:params:oauth:token-type:access_token" \
-d "resource=https://api.downstream.example.com" \
-d "scope=read"

OPA policy evaluates the exchange with the original token's subject and the requesting client. Policy can allow, reduce scopes, or deny the exchange.


DPoP (sender-constrained tokens)

DPoP (Demonstrating Proof of Possession, RFC 9449) sender-constrains access tokens so that a stolen token is useless without the private key that produced the proof. When a client requests a token with a DPoP proof, the AS binds the issued token to the client's public key via a cnf.jkt claim. Every subsequent use of the token must be accompanied by a fresh proof signed with the same key.

DPoP proof structure

A DPoP proof is a compact JWT with a specific header and claims:

Header:

{
"typ": "dpop+jwt",
"alg": "ES256",
"jwk": {
"kty": "EC",
"crv": "P-256",
"x": "<base64url-x>",
"y": "<base64url-y>"
}
}

Claims:

{
"jti": "<unique-identifier>",
"htm": "POST",
"htu": "https://pbac.example.com/token",
"iat": 1700000000
}
ClaimRequiredDescription
jtiYesUnique proof identifier. Prevents replays.
htmYesHTTP method of the request (uppercase).
htuYesURL of the request, no query string.
iatYesIssued-at timestamp. Must be within PBAC_DPOP_IAT_WINDOW_SECONDS of server time.
nonceConditionalRequired when the AS issues a nonce challenge (see below).

Token request with DPoP

curl -X POST https://pbac.example.com/token \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-H "DPoP: $DPOP_PROOF" \
-d "grant_type=client_credentials" \
-d "scope=read" \
-d "resource=https://api.example.com/fhir"

Successful response:

{
"access_token": "eyJhb...",
"token_type": "DPoP",
"expires_in": 3600,
"scope": "read"
}

The token_type: "DPoP" signals that the token is key-bound. Clients must present a DPoP proof on every API call — the token cannot be used as a Bearer token.

Introspecting a DPoP-bound token

curl -X POST https://pbac.example.com/introspect \
-H "Authorization: Bearer $RS_PAT" \
-d "token=$ACCESS_TOKEN"

The response includes the cnf claim with the public key thumbprint:

{
"active": true,
"sub": "user-123",
"client_id": "app-001",
"scope": "read",
"token_type": "DPoP",
"cnf": {
"jkt": "0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I"
},
"exp": 1700003600
}

The RS uses cnf.jkt to verify that the DPoP proof on the incoming API request was signed by the same key that was used to obtain the token.

Nonce handling

When PBAC_DPOP_NONCE_ENABLED=true, the AS requires a server-issued nonce in each DPoP proof. If the proof omits or presents a stale nonce, the AS responds with:

HTTP/1.1 400 Bad Request
DPoP-Nonce: <new-nonce>
{
"error": "use_dpop_nonce",
"error_description": "Authorization server requires nonce in DPoP proof"
}

Retry the request with the nonce added to the proof:

{
"jti": "<new-jti>",
"htm": "POST",
"htu": "https://pbac.example.com/token",
"iat": 1700000001,
"nonce": "<new-nonce>"
}

Nonces are valid for PBAC_DPOP_NONCE_TTL_SECONDS (default: 300 seconds). By default, nonces are disabled.


Resource, resource type, and scope parameters

ParameterUsage
resourceRFC 8707 resource indicator URI. The AS resolves this to a resource_type via DB lookup. Passed to OPA as input.request.resource + input.request.resolved_resource_type.
resource_typeHint when resource cannot be resolved from DB. First value used as resolved_resource_type.
scopeSpace-separated requested scopes. OPA decides the granted_scopes (may be a subset). Required for resource+scope flow.
authorization_detailsJSON array for RAR flow (RFC 9396). Mutually exclusive with resource+scope.

Grant types reference

Grant TypeDescriptionNotes
authorization_codeUser-interactive, code exchangeRequires IdP setup; supports PKCE
refresh_tokenExchange refresh token for new access tokenConfidential clients only; requires offline_access in scopes
client_credentialsMachine-to-machineNo user; confidential clients only
urn:ietf:params:oauth:grant-type:token-exchangeRFC 8693 token exchangeFor delegation and impersonation

Next steps

  • Introspection — Validate tokens and enforce policy decisions at the resource server
  • Concepts: Enforcement — Obligations, JIT single-use tokens, DPoP validation, and AuthZEN
  • Token exchange — Scope narrowing and cross-domain identity via RFC 8693