ADR-0013: Generic OIDC login ​
Status: accepted (2026-10-13). Extends ADR-0007 (identity and access); system access follows ADR-0006.
Context ​
CodeLotse signs users in through the GitHub App user authorization flow (ADR-0007). Organisations whose people sign in with an enterprise identity provider (Entra ID, Keycloak, Okta, LDAP) or who have reviewers without GitHub accounts need a second login path. Repository permissions for these users cannot come from GitHub, so authorization needs its own source of truth. The identity provider is configured at runtime by an instance admin, not through environment variables, and every outbound call to it is an SSRF surface.
Decision ​
Identity ​
- An identity provider (
identity_providers, system-only table) has an immutableslug, a display name, anhttps://issuer without query or fragment (normalised without trailing slash before the uniqueness check), a client id, a client secret encrypted with contextidentity_providers:<id>and returned only as the last four characters (secretHint), scopes (must containopenid), a username claim (defaultpreferred_username), a groups claim (defaultgroups), an optional tenant binding and an enabled flag. At most 20 providers exist per instance. Only instance admins create, change, test or delete providers; every change is audited (identity_provider.create|update|delete|test). - An OIDC user is stored with
provider = 'oidc',base_url= issuer,external_id=sub(opaque; Dex encodes user and connector id).logincomes from the configured username claim, falling back toname, thensub, with control and separator characters removed and at most 100 characters. No e-mail address is stored. - The session stores no provider token: OIDC sessions never call the identity provider after login. It stores
identity_provider_idand the groups of the configured claim (from the ID token, else from userinfo) inauth_sessions.idp_groups. More than 200 groups or a group name longer than 256 characters rejects the login instead of truncating, because truncation could drop the group that grants a role. - The session lifetime is the existing 8 hours, independent of the ID token's
exp. A session whose provider is disabled is rejected on its next request; deleting a provider deletes its sessions (foreign key cascade). - Audit actors are
oidc:<login>@<issuer host>; GitHub actors staygithub:<login>.
Authorization ​
- Tenant admins map groups to roles in
oidc_role_mappings(tenant RLS): group, provider, tenant role (admin|member), optional repository role, optional repository (none = all repositories). A repository mapping always grants tenant rolemember(schema and database check). At most 200 mappings per tenant. - Grants for an OIDC principal are computed on every request with
oidcGrantsfrom the session groups, the session's provider and the tenant's mappings. Mappings of other providers are ignored, so the same group name at another identity provider grants nothing. The highest tenant role among matching groups wins; repository roles likewise for mappings of that repository or of all repositories. No GitHub calls are made for OIDC principals. - Mapping changes and removals take effect on the next request. Group changes at the identity provider take effect at the next sign-in (at most 8 hours later), the same residual risk as cached GitHub memberships.
tenant_membershipsandrepo_membershipsare refreshed at each OIDC login so tenant and repository listings work. They are a cache for listings, never the source of truth for OIDC grants.
Tenant binding ​
- A provider bound to a tenant can only be mapped into, listed for and used to sign in to that tenant. Creating a mapping and listing providers for a tenant both require
enabled AND (bound_tenant_id IS NULL OR bound_tenant_id = tenant), and grants ignore mappings whose provider no longer satisfies this. - In the enterprise edition every provider must be bound to exactly one tenant (the binding is immutable), unbound providers are neither listed nor usable, and the issuer must resolve to a public address when it is saved. In the self-hosted edition unbound providers may be mapped by any tenant admin, into their own tenant only.
Login flow ​
GET /auth/providers(public) lists GitHub when a review app exists and every enabled OIDC provider with slug, display name and login URL; issuer and client id are not disclosed.GET /auth/oidc/:slug/logincreates state, nonce and a PKCE S256 verifier, stores them for 600 seconds in Valkey underquavon:login:<hash of a random pre-login id>, sets the pre-login id as an HttpOnly cookie on path/auth/oidcand redirects to the authorization endpoint withresponse_type=code.returnTopasses through the samesafeReturnToas the GitHub login.GET /auth/oidc/:slug/callbackconsumes the pre-login entry once (GETDEL), compares the state in constant time and lets openid-client validate the RFC 9207issparameter when advertised, the ID token signature (JWKS),iss,aud,azp,exp,nonceand the PKCE verifier. TheplainPKCE method is never sent.- A login is permitted when the user has at least one matching mapping in a tenant where the provider is usable, is an instance admin, or carries a pending setup claim.
- Every OIDC request (discovery, JWKS, token, userinfo) goes through openid-client's
customFetchwith the endpoint guard of@quavon/llm: redirects are refused and the connected address is the validated one. The enterprise edition blocks private and loopback addresses; the self-hosted edition allows private hosts (Dex inside the deployment). Both require https; plain http is accepted only withNODE_ENV=test.
Operations ​
quavon auth oidc test <slug> [--json]andPOST /api/instance/identity-providers/:id/testrun the same checks through the guarded fetch: discovery, issuer equality, https endpoints, a JWKS with at least one signing key, PKCE S256 (warning whenplainis also advertised), response typecode, configured scopes and claims advertised (warnings). Exit code 0 without failures, 1 with failures, 2 for usage errors or an unknown slug.grant-instance-admin --provider oidc --issuer <url> --login <login>makes an OIDC user an instance admin after their first sign-in.- Dex is the optional broker for LDAP and upstream OIDC identity providers (
deploy/compose/docker-compose.dex.yml). Dex's SAML connector is marked unmaintained and likely vulnerable to authentication bypasses by its maintainers and is not supported.
Consequences ​
- People without GitHub accounts can sign in and work with repositories according to their groups. Features that need a GitHub identity (avatars, profile links, "my assignment" pages) are not available to OIDC users; account linking is open.
- Revoking a group at the identity provider takes effect at the next sign-in, up to 8 hours later; removing the mapping or disabling the provider takes effect immediately.
- Instance admins choose between a self-hosted edition that trusts internal identity providers on private networks and an enterprise edition that refuses them.
- GitHub sessions now end when a membership refresh is answered with 401 for the session's user token (audit
auth.session_revoked) instead of falling back to cached roles.