Skip to content
rw3iss Auth

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"]
GrantWho may use itNeeds a userReturns
authorization_codeoauth_clients, public or confidentialYesaccess_token + refresh_token + id_token
refresh_tokenoauth_clients, public or confidentialNo (the user authenticated earlier)access_token + refresh_token
client_credentialsm2m_clients, or a confidential oauth_clients registered for itNo — there is no useraccess_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
ParameterRequiredNotes
client_idyesRendered locally as a 400 if unknown — never redirected.
redirect_uriyesMust match a registered URI byte for byte.
response_typeyescode. Nothing else is supported.
scopeyesMust include openid. Intersected with allowed_scopes.
staterecommendedEchoed back. Check it against your own session.
noncerecommendedEchoed into the id_token. Check it there.
code_challengewhen PKCE appliesRequired if the client is public or require_pkce is on — which is the default.
code_challenge_methodwith the challengeMust be S256. plain is refused.

What happens next, in order:

  1. Unknown client, or an unregistered redirect_uri → a local 400. Nothing is redirected: echoing an unvalidated URI back is precisely the open redirect this check exists to prevent.
  2. From here errors are delivered as 302 redirect_uri?error=…&error_description=…&state=….
  3. grant_types is checked here as well as at the token endpoint — a client that cannot use authorization_code is refused before anyone is walked through a consent screen they were never going to be able to complete.
  4. No session → 302 to the login UI with next= pointing back here, so the whole query survives the round trip.
  5. Consent, unless the client is trusted. A sensitive scope (civic:positions, civic:activity) re-prompts every time, even if previously granted.
  6. 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

Terminal window
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:

Terminal window
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, kid in 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 for openid gets a subject and nothing else. Full claim table: OIDC provider.
  • access_token — the first-party HS256 token, carrying the client’s app_code as its app scope. Use it at /oauth/userinfo and the rest of the API.
  • refresh_token — only useful if you requested offline_access and the client is registered for the refresh_token grant.

Failure modes

ResponseCause
400 invalid_requestcode or client_id missing.
401 invalid_clientUnknown client, or wrong/absent secret.
400 unauthorized_clientThe client’s grant_types does not list authorization_code.
400 invalid_grantCode 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_grantredirect_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.

Terminal window
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.

ResponseCause
400 invalid_requestrefresh_token missing.
401 invalid_clientUnknown client, or wrong/absent secret.
400 unauthorized_clientThe client’s grant_types does not list refresh_token.
400 invalid_grantToken expired, revoked, reused, or the account is no longer usable.
400 unsupported_grant_typeYou 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_clients row. This is the only grant that registry serves.
  • An oauth_clients row that an administrator registered for it, and that is confidential.
Terminal window
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.

ClaimValue
token_type"service"
subthe client id — not a user id
client_id, service_namethe client’s identity
scopesthe granted intersection
roles, permissionsempty 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.

ResponseCause
401 invalid_clientUnknown client, or wrong/absent secret.
401 invalid_clientThe oauth_clients row is public — “client_credentials requires a confidential client with a secret”.
400 unauthorized_clientThe oauth_clients row is confidential but not registered for the grant.
400 unsupported_grant_typeFell 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 handler

The fallback exists so that machine clients registered before the OIDC provider landed keep working with no change on their side.