Skip to content

OIDC provider

The platform can act as its own OpenID Connect issuer, letting relying party (RP) apps sign in through it. The implementation is plain Go (core/business/usecases/oidc) — no third-party identity server.

When does it turn on?

The provider surface is mounted only when OAUTH_ISSUER is set. Without it the platform remains purely a client of Gerege SSO — see Connect an app.

Endpoints

These sit at the root, outside the /api/v1 group, because their paths are fixed by the OIDC specifications.

Path Method What
/.well-known/openid-configuration GET Discovery document
/.well-known/jwks.json GET RS256 public keys
/oauth2/auth GET Authorization (code flow only)
/oauth2/token POST Token exchange
/oauth2/introspect POST RFC 7662
/oauth2/revoke POST RFC 7009
/oauth2/sessions/logout GET RP-initiated logout
/userinfo GET · POST User claims

All are public — client authentication happens inside each endpoint per the protocol.

Supported capabilities

Field Value Note
response_types_supported code Authorization code flow only
response_modes_supported query
grant_types_supported authorization_code · refresh_token · client_credentials
code_challenge_methods_supported S256 only plain defeats the point of PKCE (RFC 9700 §2.1.1)
id_token_signing_alg_values_supported RS256
subject_types_supported public Pairwise is not implemented
token_endpoint_auth_methods_supported client_secret_basic · client_secret_post · none none = public client (PKCE)
scopes_supported openid · offline_access · profile · email · nationalid A static list

Why is the scope list static?

Advertising the union of registered client scopes would expose internal gateway service names (svc:*) publicly. Discovery therefore lists only the standard scopes.

Deliberately unsupported: request / request_uri (JAR) — unused and it widens the SSRF surface.

Claims

sub · iss · aud · exp · iat · auth_time · nonce
name · given_name · family_name · given_name_en · family_name_en
email · email_verified · national_id · register_number
google_sub · google_email · google_name · google_picture

sub is the platform's stable, opaque per-citizen identifier (a user UUID), not the national ID. national_id / register_number are only included when the nationalid scope is granted.

The provider drives login and consent through its own UI: Next.js pages (/oauth/login, /oauth/consent, /oauth/logout) calling the provider usecase's challenge API.

sequenceDiagram
  participant RP as RP app
  participant P as Gerege App (issuer)
  participant U as Citizen
  RP->>P: GET /oauth2/auth?client_id&redirect_uri&code_challenge
  P-->>U: /oauth/login (login_challenge)
  U->>P: sign in with eID
  P->>P: POST /v1/provider/login/accept
  P-->>U: /oauth/consent (consent_challenge)
  U->>P: approve
  P->>P: POST /v1/provider/consent/accept
  P-->>RP: redirect_uri?code&state
  RP->>P: POST /oauth2/token (code + code_verifier)
  P-->>RP: access_token · id_token · refresh_token
Endpoint What
GET /v1/provider/login Login challenge details
POST /v1/provider/login/accept · /reject Decision
GET /v1/provider/consent Consent challenge — which scopes are requested
POST /v1/provider/consent/accept · /reject Decision
POST /v1/provider/logout/accept Logout confirmation

First-party clients: those listed in SSO_FIRSTPARTY_CLIENTS skip the consent screen.

Flow state lives in oauth_challenges, HMAC'd with SSO_STATE_KEY (≥32 bytes).

Client registration

Two surfaces, both backed by the same oauth_clients store:

Endpoint Permission What
GET/POST /v1/applications gateway.manage List / create
GET/PUT/DELETE /v1/applications/{id} gateway.manage Read / edit / delete
POST /v1/applications/{id}/rotate-secret gateway.manage Rotate the secret
PUT /v1/applications/{id}/secret gateway.manage Set the secret
PUT /v1/applications/{id}/services gateway.manage Grant gateway services

UI: Admin → Applications.

Under /admin/api/v1/..., authenticated with an admin API key — the familiar management-API pattern (Stripe/Auth0). Use Authorization: Bearer gsk_… or X-API-Key.

curl -H 'Authorization: Bearer gsk_…' \
  https://<issuer>/admin/api/v1/clients

This surface must not be public

In production the edge nginx routes /admin to the web BFF; the admin API is reachable only from the host's loopback.

Unsupported fields (present in Hydra but with no column here): backchannel / frontchannel logout URIs, DPoP, jwks/jwks_uri, audience, sector_identifier_uri, pairwise subject_type. Requests carrying them are rejected with 400 rather than silently dropped — so an operator never believes they configured something that is in fact inert.

Key management

KeyManager generates the RS256 key and publishes it through JWKS. On rotation the previous public key stays in JWKS for a while so in-flight id_tokens still verify.

Token storage

Table What
oauth_auth_codes Single-use authorization codes (with the PKCE challenge)
oauth_access_tokens Issued tokens (for introspection / revocation)
oauth_consents Consent granted by the user
oauth_challenges Transient login / consent state

All are protected by RLS — service and admin policies, plus a self policy on oauth_consents.