OIDC provider
auth-server is an OpenID Connect provider. A relying party (RP)
sends a user to the authorization endpoint, the user signs in and
consents, and the RP receives an RS256 id_token it can verify against
a public JWKS — no shared secret, no first-party trust required.
This is what makes a third-party site able to offer “Sign in with {Provider}”. FedCM sits on top of this same machinery; everything on this page is a prerequisite for it.
Discovery
curl -s https://auth.civicgate.org/.well-known/openid-configurationServed from the root of the issuer origin, never under API_PREFIX
— that is where every OIDC client looks. Cache-Control: public, max-age=3600.
{ "issuer": "https://auth.civicgate.org", "authorization_endpoint": "https://auth.civicgate.org/api/v1/oauth/authorize", "token_endpoint": "https://auth.civicgate.org/api/v1/oauth/token", "userinfo_endpoint": "https://auth.civicgate.org/api/v1/oauth/userinfo", "jwks_uri": "https://auth.civicgate.org/.well-known/jwks.json", "end_session_endpoint": "https://auth.civicgate.org/api/v1/oauth/logout", "revocation_endpoint": "https://auth.civicgate.org/api/v1/oauth/revoke", "scopes_supported": ["openid","profile","email","offline_access", "civic:location","civic:interests","civic:positions","civic:activity"], "response_types_supported": ["code"], "response_modes_supported": ["query"], "grant_types_supported": ["authorization_code","refresh_token","client_credentials"], "subject_types_supported": ["public"], "id_token_signing_alg_values_supported": ["RS256"], "token_endpoint_auth_methods_supported": ["client_secret_post","client_secret_basic","none"], "code_challenge_methods_supported": ["S256"], "claims_supported": ["sub","iss","aud","exp","iat","auth_time","nonce","azp", "email","email_verified","name","given_name","family_name", "preferred_username","picture","updated_at", "cg_app_code","cg_namespaces","cg_roles"]}Read the document; don’t hard-code the paths. API_PREFIX is
configurable, so /api/v1/oauth/token is a default, not a constant.
JWKS
curl -s https://auth.civicgate.org/.well-known/jwks.json{ "keys": [ { "kty": "RSA", "use": "sig", "alg": "RS256", "kid": "AWb6yIJg7o4cieGeiSOAUegLPEpUUJsT-6shQQFaJoY", "n": "lta_c40l5yIkOX0Pe2dFDqNcz3wqTg8wui5gLKgIFE5…", "e": "AQAB" } ]}- RS256, 2048-bit RSA.
Cache-Control: public, max-age=600. - The
kidis a thumbprint of the public key (RFC 7638-style: SHA-256 over the canonical{"e","kty","n"}JSON, base64url). The same key therefore keeps the samekidacross restarts. - The set holds exactly one key. There is no rotation support yet, so
replacing the key invalidates every outstanding
id_tokenat once. Cache the JWKS bykidand re-fetch on an unknown one — the standard client behaviour — and a future rotation costs you nothing.
Where the key comes from
First match wins:
| Order | Source | Notes |
|---|---|---|
| 1 | OIDC_PRIVATE_KEY_PEM | The PEM content itself, in the env var. |
| 2 | OIDC_PRIVATE_KEY_FILE | Path to a PEM file. |
| 3 | $OIDC_KEY_DIR/oidc-signing-key.pem | Generated on first boot (mode 0600) if absent. OIDC_KEY_DIR defaults to $HOME/.config/civicgate. |
Option 3 means a fresh deployment federates immediately with no setup —
and also that each replica generates its own key unless you give them
one. Multi-replica deployments must set 1 or 2, or RPs will
intermittently see a kid that isn’t in the JWKS they cached.
The whole OIDC + FedCM surface is enabled by exactly this: a usable signing key plus a client store. There is no separate on/off switch.
Authorization code + PKCE
The only supported flow. response_type=code, response_mode=query.
RP browser auth-server ────────────────────────────────────────────────────────────── build authorize URL ──────►│ │ GET /api/v1/oauth/authorize ─────►│ │ [sign-in + consent screen] │ │◄──── 302 redirect_uri?code=&state=│ ◄──── code + state ────────│ POST /api/v1/oauth/token ─────────────────────────────────────►│ ◄──── id_token + access_token + refresh_token ─────────────────│1. Authorization request
GET /api/v1/oauth/authorize ?client_id=rp-demo &redirect_uri=https%3A%2F%2Frp.example%2Fauth%2Fcallback &response_type=code &scope=openid%20profile%20email &state=<opaque, RP-generated> &nonce=<opaque, RP-generated> &code_challenge=<BASE64URL(SHA256(verifier))> &code_challenge_method=S256PKCE is required whenever the client is public (no secret) or has
require_pkce set — and require_pkce defaults to TRUE in the
schema, so assume it is required unless you deliberately turned it off.
state and nonce are yours to generate and yours to check: state
against your own session on the callback, nonce against the claim in
the returned id_token.
2. Token request
POST /api/v1/oauth/tokenContent-Type: application/x-www-form-urlencoded
grant_type=authorization_code&code=<the code>&redirect_uri=https://rp.example/auth/callback&client_id=rp-demo&code_verifier=<the original verifier>Confidential clients authenticate with client_secret_post or
client_secret_basic; public clients use none and rely on PKCE.
The same endpoint also serves grant_type=refresh_token (RFC 6749 §6 —
newly implemented; it was advertised and absent) and
grant_type=client_credentials. Worked examples for both:
The grants.
{ "access_token": "…", "refresh_token": "…", "id_token": "…", "token_type": "Bearer", "expires_in": 900, "scope": "openid profile email"}scope is the granted set, which may be narrower than what you
asked for — the request is intersected with the client’s
allowed_scopes and with what the user consented to. Read it; do not
assume you got what you requested.
3. The id_token
RS256, kid in the header, 10-minute lifetime. Verify iss, aud
(= your client_id), exp, and nonce.
| Claim | Present when |
|---|---|
sub, iss, aud, exp, iat, nbf | always |
nonce, auth_time, azp | always (when supplied / applicable) |
email, email_verified | scope includes email |
name, given_name, family_name, preferred_username, picture, updated_at | scope includes profile |
cg_app_code | the client has an app_code (ungated by scope) |
cg_namespaces | the user’s pools resolve (ungated by scope) |
cg_roles | the user’s global base roles resolve (ungated by scope) |
The id_token is a statement about the user, consumed once. It is
not an API credential — see
Two token families.
4. UserInfo
GET /api/v1/oauth/userinfoAuthorization: Bearer <access_token>Returns sub, email, email_verified, name, given_name,
family_name, cg_roles, plus preferred_username and cg_app_code
when set. Unauthenticated → 401 with
WWW-Authenticate: Bearer error="invalid_token".
5. End session
GET /api/v1/oauth/logout?client_id=rp-demo&post_logout_redirect_uri=https://rp.example/Redirects only to a URI in the client’s post_logout_uris (exact
match, same rule as below). Anything else gets 200 {"status":"logged_out"}
rather than a redirect — it will not become an open redirect for you.
Scopes
| Scope | Grants |
|---|---|
openid | Required. Always force-prepended if you omit it at registration. |
profile | Name, given/family name, preferred username, picture, roles. |
email | Email + verification status. |
offline_access | A refresh token. |
civic:location · civic:interests | Deployment data slices. |
civic:positions · civic:activity | Sensitive — always re-prompt for consent, even if previously granted. |
The redirect rule: exact match, nothing else
for _, u := range c.RedirectURIs { if u == uri { return true }}That is the entire matching function. A registered redirect_uri must
equal the requested one byte for byte.
- No wildcards.
https://rp.example/*is not a pattern; it is a literal string that will never match. - No prefix matching. A prefix-tolerant compare would also accept
https://rp.example.attacker.net. - No trailing-slash tolerance.
…/callbackand…/callback/are two different URIs. Register both if you use both. - No query-string tolerance. Carry per-request data in
state, not in the redirect URI.
Registration itself refuses * wildcards, # fragments, blanks, and
any non-https scheme other than http://localhost and
http://127.0.0.1.
A mismatch never redirects. It renders a local 400:
{"error":{"code":"INVALID_INPUT", "details":{"field":"redirect_uri"}, "message":"redirect_uri is not registered for this client"}}Refusing to bounce is the point — echoing an unregistered URI back is
precisely the open-redirect this check exists to prevent. After the
redirect URI validates, later errors (unsupported_response_type,
invalid_scope, PKCE failures) are delivered the normal way, as a 302
to redirect_uri?error=…&error_description=…&state=….
The token endpoint checks it again: a redirect_uri that differs from
the one stored with the code → 400 invalid_grant,
"redirect_uri does not match".
Registering a relying party
There is no dynamic OIDC registration (RFC 7591). There are two ways
to get a client row: the platform-admin API (system_admin or
super_admin), and self-service
registration
at POST /oidc/clients, which any authenticated member may call for
their own applications, with a deliberately narrower set of
capabilities (identity scopes only, PKCE forced, never trusted, no
app_code, 10 per account).
The admin path stays administrative because it can do things the self-service one cannot: registering an arbitrary redirect URI grants an application the ability to receive other people’s sessions.
POST /api/v1/admin/oauth/clientsAuthorization: Bearer <platform-admin token>Content-Type: application/json
{ "client_id": "rp-demo", "name": "Demo RP", "description": "Example relying party", "logo_url": "https://rp.example/logo.png", "redirect_uris": ["https://rp.example/auth/callback"], "post_logout_uris": ["https://rp.example/"], "allowed_scopes": ["openid", "profile", "email"], "grant_types": ["authorization_code", "refresh_token"], "app_code": "rp-demo", "trusted": false, "require_pkce": true, "public": true}| Field | Meaning |
|---|---|
client_id | Required, unique. What the RP sends and what lands in aud. |
name | Required. Shown on the consent screen and in the browser’s FedCM chooser. |
redirect_uris | Required, ≥1. Exact-match allow-list — and the source of the FedCM origin allow-list. |
post_logout_uris | Allowed post_logout_redirect_uri values. Also counts toward allowed origins. |
allowed_scopes | Ceiling on what this client may ever obtain. Defaults to ["openid","profile","email"]. |
grant_types | Which grants this client may use. Defaults to ["authorization_code","refresh_token"]. |
app_code | Labels the token with an app registration. It does not gate who may sign in — see Users, apps and tenancy. |
trusted | First-party client; may skip the consent screen. |
require_pkce | Defaults true. Forced on for public clients. |
public | true → no secret is issued. Correct for SPAs and mobile apps. |
status | active / otherwise. Settable on PATCH. |
The rest of the admin surface
| Method | Path | Effect |
|---|---|---|
GET | /api/v1/admin/oauth/clients | List clients + the server’s supported_scopes vocabulary. |
POST | /api/v1/admin/oauth/clients | Create. Returns the secret once. |
PATCH | /api/v1/admin/oauth/clients/{clientId} | Update only the supplied fields. |
POST | /api/v1/admin/oauth/clients/{clientId}/rotate-secret | New secret; old one dies immediately. |
DELETE | /api/v1/admin/oauth/clients/{clientId} | Also drops the client’s codes and standing consents. |
Listed clients carry has_secret and is_public booleans — never the
secret itself.
A parallel, owner-scoped surface exists at /api/v1/oidc/clients for
members managing their own applications — same verbs, fewer capabilities.
See Registering an application.
There is a ready-made admin UI: OidcClientManager from
@rw3iss/auth-client
(@rw3iss/auth-client/preact/ui/admin), backed by the SDK’s
OidcClientsFlow.
Errors
The token endpoint uses the RFC 6749 §5.2 shape, with
Cache-Control: no-store and Pragma: no-cache:
{ "error": "invalid_grant", "error_description": "redirect_uri does not match" }| Code | Typical cause |
|---|---|
invalid_request | Missing/malformed parameter; code_challenge_method other than S256. |
invalid_client | Unknown client, or bad/absent client authentication. |
invalid_grant | Code expired, already used, or redirect_uri mismatch. |
unsupported_response_type | Anything but code. |
server_error | Signing or storage failure. |
The authorization endpoint’s pre-redirect failures (unknown
client_id, unregistered redirect_uri) use the platform’s own
envelope instead, because there is nowhere safe to redirect:
{"error":{"code":"INVALID_INPUT","details":{"field":"client_id"}, "message":"Unknown or inactive client"}}Read next
- Applications & credentials — the two client registries, every client property, the grants, the scopes, and who may use each.
- FedCM — the same identity, asked for by the browser instead of a redirect.
- Provider identity — what an RP reads to render the button.
- Sign in with {Provider} — the worked integration.
- Server source:
internal/auth/oidc/(store, keys, token minting),internal/api/handlers/oidc_handler.go, migration025_oidc_provider.up.sql.