Skip to content

Get tokens for every grant

Every grant is a form-encoded POST to http://localhost:8080/{issuer}/token. These recipes use the zero-config default issuer; swap default for any id and it materializes on first touch. Each curl uses -d (which sends application/x-www-form-urlencoded).

Testing only — secrets are never validated

mock-oidc accepts any client_secret (or none) for every grant and never checks it. Passwords, assertion signatures, and subject-token signatures are not verified either. This is deliberate; the server must never front real traffic.

At a glance, this is what each grant hands back:

Grant Tokens returned Default sub
client_credentials access client_id
password id + access username
authorization_code id + access + refresh login user / configured / random UUID
refresh_token access (+ id if a nonce was cached) same as original
jwt-bearer access assertion's sub
token-exchange access subject token's sub

For the full claim rules (aud precedence, azp/tid, typ) see Tokens and claims.

client_credentials

curl -sS -X POST http://localhost:8080/default/token \
  -d grant_type=client_credentials \
  -d client_id=orders-service \
  -d client_secret=unchecked \
  -d scope=api://orders
#   => { "token_type":"Bearer", "access_token":"eyJ...", "expires_in":3600,
#   =>   "scope":"api://orders" }

Returns an access token only — no id_token, no refresh_token. sub defaults to client_id. The non-OIDC scope value becomes the access token's aud.

password (ROPC)

curl -sS -X POST http://localhost:8080/default/token \
  -d grant_type=password \
  -d username=alice \
  -d password=anything \
  -d scope=openid
#   => { "token_type":"Bearer", "access_token":"eyJ...", "id_token":"eyJ...",
#   =>   "expires_in":3600, "scope":"openid" }

Returns an id token + access token, but no refresh token. sub == username, and any password is accepted.

refresh_token

There is no direct way to request a refresh token — only the authorization_code grant issues one. Get a code, exchange it, then redeem the refresh token it returns.

First mint a code. With interactiveLogin off (the default) a bare GET /authorize auto-issues one via redirect:

curl -sS -i "http://localhost:8080/default/authorize?response_type=code&client_id=web-app&redirect_uri=http://localhost:3000/callback&scope=openid&state=xyz"
#   => HTTP/1.1 302 Found
#   => Location: http://localhost:3000/callback?code=THE_CODE&state=xyz

Exchange the code for the token set (this is where the refresh token comes from):

curl -sS -X POST http://localhost:8080/default/token \
  -d grant_type=authorization_code \
  -d code=THE_CODE \
  -d client_id=web-app \
  -d redirect_uri=http://localhost:3000/callback
#   => { "access_token":"eyJ...", "id_token":"eyJ...", "refresh_token":"THE_REFRESH", ... }

Now redeem the refresh token as often as you need:

curl -sS -X POST http://localhost:8080/default/token \
  -d grant_type=refresh_token \
  -d refresh_token=THE_REFRESH \
  -d client_id=web-app \
  -d scope=openid
#   => { "token_type":"Bearer", "access_token":"eyJ...", "expires_in":3600, "scope":"openid" }

Each redemption re-mints a fresh access token (new jti/iat/exp, same sub). Rotation is off by default, so the same refresh_token keeps working and no new one is returned. An id_token comes back only if the original /authorize request carried a nonce (add &nonce=... above to get one on every refresh).

An unknown token returns invalid_grant; redeeming a token minted by a different issuer returns invalid_grant with "different issuer" in the description.

authorization_code

Shown as the first two steps of the refresh recipe above: obtain a code from /authorize, then POST it to /token with grant_type=authorization_code. It returns id + access + refresh tokens, and it is the only grant that adds azp == client_id. The code is single-use and is burned even on a failed PKCE check.

For the full browser round trip — interactive login, PKCE (plain/S256), response_mode, and nonce — see Drive the authorization-code flow.

jwt-bearer

The assertion JWT is parsed, not signature-verified, so a literal dummy signature works. Build base64url(header).base64url(payload).dummy yourself:

# Base64url-encode stdin, no padding. Needs openssl (preinstalled on macOS and most Linux).
b64url() { openssl base64 -A | tr '+/' '-_' | tr -d '='; }

header=$(printf '%s' '{"alg":"RS256","typ":"JWT"}' | b64url)
payload=$(printf '%s' '{"sub":"svc-account","scope":"api://reports"}' | b64url)
assertion="$header.$payload.dummy"          # third segment is a literal dummy signature

curl -sS -X POST http://localhost:8080/default/token \
  -d grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer \
  --data-urlencode "assertion=$assertion" \
  -d scope=api://reports
#   => { "token_type":"Bearer", "access_token":"eyJ...", "expires_in":3600,
#   =>   "scope":"api://reports" }

Returns an access token only; issued_token_type is omitted. All assertion claims are copied into the token, then iss/exp/nbf/iat/jti/aud are re-stamped.

Scope resolves as request scope → the assertion's scope claim → invalid_request. Drop -d scope=... above and the token picks up "scope":"api://reports" from the assertion payload instead. A blank assertion returns invalid_request.

token-exchange

The subject_token is also parsed, not verified — reuse the b64url helper and header from the previous recipe. Client authentication is required; the simplest form is client_id + client_secret fields.

subject_payload=$(printf '%s' '{"sub":"alice","email":"alice@example.com"}' | b64url)
subject_token="$header.$subject_payload.dummy"   # $header reused from the jwt-bearer recipe

curl -sS -X POST http://localhost:8080/default/token \
  -d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
  --data-urlencode "subject_token=$subject_token" \
  -d subject_token_type=urn:ietf:params:oauth:token-type:access_token \
  -d client_id=exchange-client \
  -d client_secret=unchecked \
  -d audience=https://api.internal.example
#   => { "token_type":"Bearer", "access_token":"eyJ...", "expires_in":3600,
#   =>   "issued_token_type":"urn:ietf:params:oauth:token-type:access_token" }

Returns an access token only, with issued_token_type=urn:ietf:params:oauth:token-type:access_token and no scope field. The audience param sets the token's aud — but only when the matched issuer has no configured callback audience (a configured audience wins).

Client auth is not optional here

Omit client_id/client_secret and the request fails with invalid_request mentioning ClientAuthentication. Only this grant enforces that a client authenticates (the secret is still discarded).

grant_type errors

  • A blank grant_type returns invalid_request.
  • An unknown grant_type returns invalid_grant.

Both use the OAuth2 error envelope: {"error":"...","error_description":"..."}.

Skip the flow entirely with /_mock/mint

When you just need a token and do not care which grant produced it, mint one directly through the control plane. The result is byte-identical to a granted token: it verifies against /default/jwks and is accepted at /default/userinfo.

curl -sS -X POST http://localhost:8080/_mock/mint \
  -H 'Content-Type: application/json' \
  -d '{"issuer":"default","subject":"alice","audience":["api://orders"],
       "scope":["openid"],"clientId":"web-app","kind":"access_token"}'
#   => { "token":"eyJ...", "kid":"default", "algorithm":"RS256", "issuer":"...", ... }

See Control plane for the full /_mock/mint body, plus scenarios that shape claims on the next real grant.