The grants
auth-server implements three OAuth 2.0 grants, all at
POST /api/v1/oauth/token. The list is not hand-written anywhere: the
discovery document’s grant_types_supported is derived from
oidc.SupportedGrants, which is the same constant the endpoint dispatches
on — so it cannot advertise something it does not serve.
"grant_types_supported": ["authorization_code", "refresh_token", "client_credentials"]| Grant | Who may use it | Needs a user | Returns |
|---|---|---|---|
authorization_code | oauth_clients, public or confidential | Yes | access_token + refresh_token + id_token |
refresh_token | oauth_clients, public or confidential | No (the user authenticated earlier) | access_token + refresh_token |
client_credentials | m2m_clients, or a confidential oauth_clients registered for it | No — there is no user | access_token only |
A grant not listed in the client’s grant_types is refused with
unauthorized_client. An empty array means the default —
authorization_code + refresh_token. See
grant_types.
authorization_code
The interactive flow: a person signs in at the auth server and the application receives a code it exchanges for tokens. The application never sees the person’s password.
Who may use it: any oauth_clients row registered for it — which is
every one by default. m2m_clients cannot: there is no user.
1. Send the person to the authorization endpoint
GET /api/v1/oauth/authorize ?client_id=marketplace-web &redirect_uri=https%3A%2F%2Fmarketplace.example%2Fauth%2Fcallback &response_type=code &scope=openid%20profile%20email%20offline_access &state=<opaque, you generate and check it> &nonce=<opaque, you generate and check it> &code_challenge=<BASE64URL(SHA256(verifier))> &code_challenge_method=S256| Parameter | Required | Notes |
|---|---|---|
client_id | yes | Rendered locally as a 400 if unknown — never redirected. |
redirect_uri | yes | Must match a registered URI byte for byte. |
response_type | yes | code. Nothing else is supported. |
scope | yes | Must include openid. Intersected with allowed_scopes. |
state | recommended | Echoed back. Check it against your own session. |
nonce | recommended | Echoed into the id_token. Check it there. |
code_challenge | when PKCE applies | Required if the client is public or require_pkce is on — which is the default. |
code_challenge_method | with the challenge | Must be S256. plain is refused. |
What happens next, in order:
- Unknown client, or an unregistered
redirect_uri→ a local400. Nothing is redirected: echoing an unvalidated URI back is precisely the open redirect this check exists to prevent. - From here errors are delivered as
302 redirect_uri?error=…&error_description=…&state=…. grant_typesis checked here as well as at the token endpoint — a client that cannot useauthorization_codeis refused before anyone is walked through a consent screen they were never going to be able to complete.- No session →
302to the login UI withnext=pointing back here, so the whole query survives the round trip. - Consent, unless the client is
trusted. A sensitive scope (civic:positions,civic:activity) re-prompts every time, even if previously granted. 302 redirect_uri?code=<code>&state=<state>.
The code is single-use, bound to one client and one redirect URI, and
lives 2 minutes. It is stored SHA-256 hashed, and redemption is an
atomic UPDATE … WHERE consumed_at IS NULL … RETURNING, so two
simultaneous redemptions cannot both win.
2. Exchange the code
curl -X POST https://auth.civicgate.org/api/v1/oauth/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d 'grant_type=authorization_code' \ -d 'code=Zm9vYmFy…' \ -d 'redirect_uri=https://marketplace.example/auth/callback' \ -d 'client_id=marketplace-web' \ -d 'client_secret=cgs_4bK8s2Qm…' \ -d 'code_verifier=<the original verifier>'Confidential clients may use client_secret_basic instead, and Basic wins
when both are present:
curl -X POST https://auth.civicgate.org/api/v1/oauth/token \ -u 'marketplace-web:cgs_4bK8s2Qm…' \ -d 'grant_type=authorization_code' -d 'code=…' -d 'code_verifier=…'Public clients send neither — token_endpoint_auth_method: none — and the
code_verifier takes the secret’s place.
{ "access_token": "eyJhbGciOiJIUzI1NiIs…", "refresh_token": "eyJhbGciOiJIUzI1NiIs…", "id_token": "eyJhbGciOiJSUzI1NiIs…", "token_type": "Bearer", "expires_in": 900, "scope": "openid profile email offline_access"}Cache-Control: no-store, Pragma: no-cache.
3. What comes back
id_token— RS256, 10-minute lifetime,kidin the header, verify against/.well-known/jwks.json. A statement about the user, not an API credential. Claims are filtered by scope: a client that asked only foropenidgets a subject and nothing else. Full claim table: OIDC provider.access_token— the first-party HS256 token, carrying the client’sapp_codeas its app scope. Use it at/oauth/userinfoand the rest of the API.refresh_token— only useful if you requestedoffline_accessand the client is registered for therefresh_tokengrant.
Failure modes
| Response | Cause |
|---|---|
400 invalid_request | code or client_id missing. |
401 invalid_client | Unknown client, or wrong/absent secret. |
400 unauthorized_client | The client’s grant_types does not list authorization_code. |
400 invalid_grant | Code unknown, already redeemed, or expired — all three collapse to one message, because a replayed code probably means interception and naming the failure helps only an attacker. |
400 invalid_grant | redirect_uri differs from the one the code was issued against, or PKCE verification failed, or PKCE was required and no challenge was recorded. |
refresh_token
RFC 6749 §6. Exchange a refresh token for a fresh pair when the access token expires, without sending the person back through a login.
Who may use it: any oauth_clients row registered for it — the
default. You also need to have obtained a refresh token in the first
place, which means requesting offline_access.
curl -X POST https://auth.civicgate.org/api/v1/oauth/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d 'grant_type=refresh_token' \ -d 'refresh_token=eyJhbGciOiJIUzI1NiIs…' \ -d 'client_id=marketplace-web' \ -d 'client_secret=cgs_4bK8s2Qm…'{ "access_token": "…", "refresh_token": "…", "token_type": "Bearer", "expires_in": 900}No id_token. This grant proves nothing new about the person — it
extends an existing session. If you need a fresh assertion of identity,
run the authorization-code flow again.
Rotation, with reuse detection. The response carries a new refresh token and the presented one is spent. Presenting a spent-but-otherwise valid token again means somebody has a copy: the whole refresh-token family is revoked and the event is audited. Store the new token every time.
App scope survives. The refresh token carries the app_code it was
minted under, so the new access token keeps its app_id/app_code claims
and the permission union for that app’s services.
The refresh path is the first-party AuthService.RefreshTokens —
deliberately the same implementation a first-party session uses, so an
OIDC refresh and a session refresh cannot drift apart in how they revoke.
That also means the user is re-checked: a suspended or deleted account
fails here even with a valid token.
| Response | Cause |
|---|---|
400 invalid_request | refresh_token missing. |
401 invalid_client | Unknown client, or wrong/absent secret. |
400 unauthorized_client | The client’s grant_types does not list refresh_token. |
400 invalid_grant | Token expired, revoked, reused, or the account is no longer usable. |
400 unsupported_grant_type | You reached the m2m handler — see the routing note below. |
client_credentials
RFC 6749 §4.4. The application authenticates as itself. There is no user, no consent, and no refresh token (§4.4.3 forbids one — just ask again).
Who may use it:
- Every
m2m_clientsrow. This is the only grant that registry serves. - An
oauth_clientsrow that an administrator registered for it, and that is confidential.
curl -X POST https://auth.civicgate.org/api/v1/oauth/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d 'grant_type=client_credentials' \ -d 'client_id=nightly-reconcile' \ -d 'client_secret=…' \ -d 'scope=users:read orgs:read'{ "access_token": "eyJhbGciOiJIUzI1NiIs…", "token_type": "Bearer", "expires_in": 900, "scope": "users:read orgs:read"}The m2m path additionally returns an absolute expires_at, for callers
that prefer wall-clock to relative seconds.
scope is intersected with what the client owns. Omitting it means “the
client’s full configured scopes” on the m2m path; on the oauth_clients
path it is intersected with allowed_scopes, so an empty request yields
an empty scope set — always send scope here.
The token is a SERVICE PRINCIPAL, not a user
Both registries mint the token through one function
(jwt.Service.ServiceToken), deliberately — a second copy of the claim
set would be a second definition of what a service principal is, and one
of them would end up missing token_type and being mistaken for a user.
| Claim | Value |
|---|---|
token_type | "service" |
sub | the client id — not a user id |
client_id, service_name | the client’s identity |
scopes | the granted intersection |
roles, permissions | empty arrays |
user fields (uid, email, …) | absent |
Revocation: revoke the client and future grants fail immediately, while outstanding tokens age out at natural expiry (~15 min). No row records an issued service token.
| Response | Cause |
|---|---|
401 invalid_client | Unknown client, or wrong/absent secret. |
401 invalid_client | The oauth_clients row is public — “client_credentials requires a confidential client with a secret”. |
400 unauthorized_client | The oauth_clients row is confidential but not registered for the grant. |
400 unsupported_grant_type | Fell through to the m2m handler, which serves nothing else. |
How one endpoint serves two registries
POST /oauth/token is a single URL because that is what discovery
advertises. Dispatch works like this:
POST /oauth/token │ ├─ grant_type not in SupportedGrants ──────────► 400 unsupported_grant_type │ ├─ authorization_code ─────────────────────────► oauth_clients only │ ├─ refresh_token ──┬─ client id is an oauth_client ──► oauth_clients │ └─ unknown id, no Basic auth ─────► m2m handler │ └─ client_credentials ─┬─ id is an oauth_client ─────► oauth_clients └─ otherwise ────────────────► m2m handlerThe fallback exists so that machine clients registered before the OIDC provider landed keep working with no change on their side.
Read next
- Scopes — what you may ask for and what it grants.
- Client properties →
grant_types— where each check happens. - OIDC provider — discovery, JWKS,
id_tokenclaims, end-session.