Skip to content
rw3iss Auth

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

Terminal window
curl -s https://auth.civicgate.org/.well-known/openid-configuration

Served 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

Terminal window
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 kid is 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 same kid across restarts.
  • The set holds exactly one key. There is no rotation support yet, so replacing the key invalidates every outstanding id_token at once. Cache the JWKS by kid and 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:

OrderSourceNotes
1OIDC_PRIVATE_KEY_PEMThe PEM content itself, in the env var.
2OIDC_PRIVATE_KEY_FILEPath to a PEM file.
3$OIDC_KEY_DIR/oidc-signing-key.pemGenerated 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=S256

PKCE 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/token
Content-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.

ClaimPresent when
sub, iss, aud, exp, iat, nbfalways
nonce, auth_time, azpalways (when supplied / applicable)
email, email_verifiedscope includes email
name, given_name, family_name, preferred_username, picture, updated_atscope includes profile
cg_app_codethe client has an app_code (ungated by scope)
cg_namespacesthe user’s pools resolve (ungated by scope)
cg_rolesthe 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/userinfo
Authorization: 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

ScopeGrants
openidRequired. Always force-prepended if you omit it at registration.
profileName, given/family name, preferred username, picture, roles.
emailEmail + verification status.
offline_accessA refresh token.
civic:location · civic:interestsDeployment data slices.
civic:positions · civic:activitySensitive — 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. …/callback and …/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/clients
Authorization: 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
}
FieldMeaning
client_idRequired, unique. What the RP sends and what lands in aud.
nameRequired. Shown on the consent screen and in the browser’s FedCM chooser.
redirect_urisRequired, ≥1. Exact-match allow-list — and the source of the FedCM origin allow-list.
post_logout_urisAllowed post_logout_redirect_uri values. Also counts toward allowed origins.
allowed_scopesCeiling on what this client may ever obtain. Defaults to ["openid","profile","email"].
grant_typesWhich grants this client may use. Defaults to ["authorization_code","refresh_token"].
app_codeLabels the token with an app registration. It does not gate who may sign in — see Users, apps and tenancy.
trustedFirst-party client; may skip the consent screen.
require_pkceDefaults true. Forced on for public clients.
publictrue → no secret is issued. Correct for SPAs and mobile apps.
statusactive / otherwise. Settable on PATCH.

The rest of the admin surface

MethodPathEffect
GET/api/v1/admin/oauth/clientsList clients + the server’s supported_scopes vocabulary.
POST/api/v1/admin/oauth/clientsCreate. Returns the secret once.
PATCH/api/v1/admin/oauth/clients/{clientId}Update only the supplied fields.
POST/api/v1/admin/oauth/clients/{clientId}/rotate-secretNew 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" }
CodeTypical cause
invalid_requestMissing/malformed parameter; code_challenge_method other than S256.
invalid_clientUnknown client, or bad/absent client authentication.
invalid_grantCode expired, already used, or redirect_uri mismatch.
unsupported_response_typeAnything but code.
server_errorSigning 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"}}