0019 - Deployment-wide OIDC federated sign-in
OIDC authentication decision for console SSO
- Status: Accepted
- Date: 2026-06-03
- Deciders: nexus core team
- Tags: auth, oidc, identity, console
Context and Problem Statement
Section titled “Context and Problem Statement”Nexus authenticates console users with email/password sessions. Operators running a deployment for a company need their users to sign in with an existing identity provider (Google Workspace, Microsoft Entra ID) and need to control who may create an account. Per-workspace SSO with subdomain routing is a later, subscription-gated capability; the immediate need is a single deployment-wide set of providers and a registration policy.
Decision Drivers
Section titled “Decision Drivers”- Reuse the existing user, identity, session, membership, and audit primitives rather than building a second provisioning path.
- Keep secrets and TLS aligned with 0001 - Rust microservices with axum + tonic for backend: rustls with the aws-lc-rs provider, no native-tls/openssl/ring.
- Configuration must be deployment-wide and operator-controlled now, without precluding per-workspace connections later.
- The browser must only ever talk to the console origin and the IdP so the session cookie lands on the console as it does for password login.
Considered Options
Section titled “Considered Options”- Hand-rolled OIDC client — minimal dependencies, but reimplements JWKS rotation, discovery, and ID-token verification, which is security-sensitive.
openidconnectcrate, env-driven providers — a maintained library for Authorization Code + PKCE, discovery, and ID-token verification, configured from the environment.- Per-workspace DB-backed connections with KMS-wrapped secrets now — the eventual model, but heavier than the current need and entangled with subdomain routing and subscription tiers.
Decision Outcome
Section titled “Decision Outcome”Chosen option: openidconnect crate with env-driven, deployment-wide
providers.
The auth service resolves providers from the environment behind an
OidcProviderRegistry. All connections share one discovery and PKCE path; a
connection’s profile selects only the extra validation applied to the
normalized claims. Three profiles exist: google,
microsoft, and generic. Each login runs Authorization Code + PKCE: the auth
service builds the authorization URL and persists single-use state (hashed,
short-TTL) in oidc_login_states; on callback it exchanges the code and uses
the connection’s configured claim-source order. The default is a verified ID
token (signature, issuer, audience, expiry, nonce). A connection can instead
request claims from the discovered UserInfo endpoint with the access token, or
list both sources in fallback order. Google requires a verified email;
Microsoft requires the tid claim to match the configured tenant. Optional
email-domain allowlists are enforced server-side.
generic is the standards-only profile, applying no vendor-specific claim
checks, so any conformant issuer is configuration rather than code. Two
properties of real issuers are handled explicitly rather than assumed:
- Email verification is a declared posture. Issuers disagree about
email_verified: some assert it, some never emit it, and a self-hosted issuer emits it only when an operator maps it into the token’s attribute contract. A connection therefore declaresAUTH_OIDC_<KEY>_EMAIL_VERIFICATIONasrequire(the default, and the only behaviour forgoogleandmicrosoft) ortrust-issuer.trust-issueraccepts the issuer’s asserted address on the same reasoning themicrosoftprofile already applies to a tenant-bound address, and is accepted only on agenericconnection that also states its email-domain boundary as a list or an explicit*. That allowlist is what makes the trust decision reviewable. - Trust anchors are operator input. The discovery client trusts only the
bundled root set and never reads the system trust store, so an issuer behind a
private or enterprise CA is unreachable until its chain is supplied through
AUTH_OIDC_CA_BUNDLE_PATH. Supplied roots are added alongside the built-in ones so private-CA and publicly-trusted connections coexist, and an unusable path fails startup rather than degrading to the default roots. - Client authentication is a declared method. A connection authenticates
the token exchange with
AUTH_OIDC_<KEY>_CLIENT_AUTHasclient-secret(the default: HTTP Basic with the shared secret) ortls(RFC 8705tls_client_auth: the deployment-wide client certificate named byAUTH_OIDC_CLIENT_TLS_CERT_PATH/AUTH_OIDC_CLIENT_TLS_KEY_PATHis presented during the TLS handshake, andclient_idtravels in the request body with no shared secret). The identity is deployment-wide for the same reason the CA bundle is — it is a property of the deployment, not of a connection — and it is only ever sent to an issuer that requests a certificate. Atlsconnection with a configured secret, or without a configured identity, fails startup. Issuers that publish their mutual-TLS token endpoint undermtls_endpoint_aliasesor serve it on a separate port state it throughAUTH_OIDC_<KEY>_TOKEN_ENDPOINT, which overrides the discovered token endpoint for the exchange only.
Identity resolution reuses user_identities(issuer, subject): an existing
linked identity always logs in; otherwise a verified email may link to an
existing user (when AUTH_OIDC_LINK_BY_EMAIL is set) or just-in-time create a
passwordless user, gated by AUTH_ALLOW_REGISTRATION (true / invite /
false). New users provision through the existing Personal Workspace bootstrap.
The session JWT carries amr reflecting the authentication method.
The console proxies the flow: a browser hits a console route that calls the auth
service, redirects to the IdP, and the IdP returns to a console callback route
that exchanges the result and sets the existing session cookies. The
openidconnect crate uses its rustls-tls feature, keeping TLS off native-tls.
Consequences
Section titled “Consequences”- Operators configure providers and registration policy entirely from the environment; client secrets are not stored in the database for this slice.
- Federated users and memberships flow through the same primitives as password users, so RBAC, audit, and data-plane authorization are unchanged.
- OIDC lifecycle steps emit
auth.oidc.login_started/login_succeeded/login_deniedandauth.identity.linkedaudit events. - Adding an issuer is configuration, not code: a new conformant provider needs a
genericconnection, not a new profile. Vendor names stay out ofOidcProfile. - An issuer that requires mutual-TLS client authentication (a common
PingFederate posture) is configuration too:
tlsclient auth plus the deployment’s certificate, key, CA bundle, and, where published, the mTLS token endpoint.private_key_jwtremains unimplemented. trust-issuermoves the trust boundary from the token to the operator’s domain allowlist. It is deliberately narrow —genericonly, and only with a stated boundary — so it cannot silently become the default posture.- Claim-source order is declared per connection.
id-tokenpreserves the nonce-bound ID-token flow.userinfouses the access token at the discovered UserInfo endpoint for identity claims only. A comma-separated list such asuserinfo,id-tokentries each source from left to right. The access token is never persisted or reused as a Nexus session credential. - Group and role claims are not consumed. An issuer’s group membership carries no Nexus authority, so roles remain managed inside Nexus.
- Per-workspace OIDC connections (DB + KMS-wrapped secrets), subdomain-based SSO entrypoints, SCIM, and SAML remain deferred; the registry boundary lets them be added without changing the protocol code.
