Version v0.4.7 of the documentation is no longer actively maintained. The site that you are currently viewing is an archived snapshot. For up-to-date documentation, see the latest version.

ADR 0057: OIDC/SSO authentication with fail-closed tenant pinning

ADR 0057: OIDC/SSO authentication with fail-closed tenant pinning

Status: Proposed Date: 2026-08-19 Relates: ADR 0008 (JWT auth — the Authenticator seam OIDC plugs into; the _token this flow mints and the per-request reload that governs live permissions), ADR 0035 (keyless-first — the verify path uses the issuer’s public JWKS, no secret), ADR 0055 (token liveness/revocation — bounds the OIDC session lifetime), ADR 0014 (supply chain — the new go-oidc dependency), ADR 0049 (edition gating precedent), ADR 0018 (embedded login UI the _token cookie drives)

Context

Leoflow authenticates with HS256 JWTs (internal/auth/jwt.go): a username/password POST /auth/token mints a token carrying {tenant_id, email, roles}, and the middleware accepts it as a bearer or the _token cookie. The Authenticator interface was documented from the start as keeping OIDC/LDAP pluggable, and the users table already carries oidc_subject/oidc_provider (unique) with a nullable password_hash — the schema anticipated SSO, but no code used those columns and auth.provider: oidc booted closed as unimplemented.

The enterprise pivot needs SSO against Entra ID / Google Cloud Identity / Okta, with fail-closed mapping of external identities to Leoflow tenants and roles. OIDC is a login flow, not a new request-path authenticator: the callback verifies the IdP’s ID token and mints Leoflow’s own _token, so the middleware, /ui/auth/*, and the API are unchanged. The per-request reload from ADR 0008’s prerequisite work (roles/permissions/is_active reloaded on every verify) remains the authority for live permissions and gives revocation-within-TTL.

An identity/zero-trust specialist review of the design flagged five HIGH issues; all five are folded into the decisions below (H1→D6, H2→D8, H3→relies on the role-ladder prerequisite, H4→D10, H5→the audit decision).

Decisions

D1. Flow = Authorization Code + PKCE. Endpoints GET /api/v2/auth/oidc/login and GET /api/v2/auth/oidc/callback, registered under the existing public /api/v2/auth/ prefix. The state, nonce, and PKCE code_verifier are carried in a short-lived, signed, HttpOnly; Secure; SameSite=Lax cookie (stateless — no new session store). The cookie is signed with a key derived from the app’s HS256 secret, distinct from the session-token key, so a state cookie can never cross-verify as a session token. Implicit flow is rejected.

D2. Session = the _token JWT cookie. On success the callback mints Leoflow’s own _token via MintUserToken and sets it server-side HttpOnly; Secure; SameSite=Lax (hardened vs the credential login page’s client-JS cookie). The OIDC session length equals the JWT TTL; mid-session revocation is the per-request is_active reload (revocation-within-TTL). The post-login redirect reuses sanitizeNext (open-redirect safe).

D3. Identity match = (oidc_provider, oidc_subject), email only as a verified link key. FindUserByOIDCSubject resolves a returning user by the immutable pair (loading roles + permissions + is_active, mirroring the credential path). oidc_provider is the pinned issuer URL — stable and unambiguous per deployment. email is trusted only when email_verified == true (absent ⇒ false).

D4. JIT provisioning = OFF by default (auth.oidc.jit_provisioning: false). Default requires a pre-existing user row (no accidental over-privilege). When ON, a first OIDC login creates a users row (email + oidc_subject/provider, NULL password) with the roles from D5. When OFF and no row matches, the login is rejected (403) — never auto-created.

D5. Group→role mapping = explicit config map to existing DB role names, default-DENY, IdP-authoritative. auth.oidc.role_mappings: {<idp_group_value>: <leoflow_role>}. An unmapped group grants no role. On every login the mapped set is written to the DB user_roles as the user’s EXACTLY-current roles (see the reconciliation consequence below) and also carried in the minted token; the per-request reload then turns those DB roles into permissions. The identity provider therefore stays authoritative for an OIDC user’s roles.

D5a. default_role (opt-in UX softener). auth.oidc.default_role softens default-deny without weakening the secure default: when an authenticated user resolves to zero mapped roles and default_role is set, they are granted that single fallback role (operators are advised to use a read-only role such as viewer). Empty (the default) keeps strict deny — an unmapped user gets no role. The role must exist for the resolved tenant; an unknown default_role fails the login closed (audited), on both the pre-provisioned and the JIT paths.

D6. Tenant pin = issuer-locked + IdP claim (tid/hd), fail-closed. NOT email-domain (H1). (a) the config pins the org’s single-tenant issuer URL and every ID token whose iss differs is rejected; (b) the tenant is resolved from an IdP-issued claim — Entra tid, Google Workspace hd — via auth.oidc.tenant_claims: {<value>: <tenant_name>}; (c) email_verified == true is required before any email is trusted; (d) an absent/unmapped claim or an issuer mismatch is a 403 that never falls back to default. Email domain is never the pin (spoofable under Entra /common or “any Google account”).

D6a. allowed_email_domains (login-level allowlist, layered on the pin — NOT the pin). auth.oidc.allowed_email_domains gates every OIDC login (pre-provisioned and JIT). It runs only after the issuer pin, the tid/hd pin, and email_verified == true have passed, so the email domain is trustworthy at that point. Empty imposes no restriction (the tid/hd pin is the sole boundary). Non-empty admits a login only when the verified email’s domain is in the list; every other login is 403 (audited). It is configured at install time (env LEOFLOW_AUTH_OIDC_ALLOWED_EMAIL_DOMAINS / Helm value). It does not substitute for tid/hd — H1 stays closed.

D7. provider: jwt stays default; oidc is opt-in AND Pro-gated. validateProvider allows provider: oidc only when ui.edition == "pro" AND the required config is present (issuer, client_id, redirect_url, https issuer); otherwise boot fails closed with an actionable message. The JWT secret stays required because the callback mints the app’s own _token.

D8. Break-glass = designated local admin(s) only (H2). When provider: oidc, the credential path POST /auth/token accepts ONLY the emails in auth.oidc.break_glass_emails; every other password login is rejected. Each break-glass attempt (allowed, denied, bad-credentials) is audited. Default posture is effectively SSO-only. dev_no_auth stays loopback-only. In JWT mode the credential path is ungated and unchanged.

D9. Verify path is keyless (ADR 0035-aligned). ID-token verification uses the IdP’s public OIDC discovery + JWKS — no secret. The client_secret is used only for the code exchange, injected via LEOFLOW_AUTH_OIDC_CLIENT_SECRET (env, never persisted, never logged). All auth.oidc.* keys are registered in serverDefaults so viper binds the scalar LEOFLOW_AUTH_OIDC_* env vars; the map/slice leaves (role_mappings, tenant_claims, allowed_email_domains, break_glass_emails) are config-file / Helm-values driven.

D10. Dependency = github.com/coreos/go-oidc/v3 (+ golang.org/x/oauth2, promoted to direct). go-oidc verifies the signature (against the discovered JWKS), the audience, and the issuer, and its NewProvider pins the issuer at discovery. It does not verify nonce, azp, the callback state/CSRF binding, or clock skew — these are enforced explicitly in Leoflow code (H4): state bound to and compared against the signed cookie; RFC 9207 iss response param checked where present; aud == client_id; azp == client_id when present; nonce against the state cookie (constant-time); and exp/iat/nbf with a small configurable clock skew (default 60s), which is why go-oidc’s leeway-less expiry check is disabled and this package is the single time authority.

Audit (H5). Every auth event is recorded to the existing audit sink (RecordAuthEvent, alongside RecordUserCreatedAudit): login success/failure, tenant-pin rejection, JIT provisioning, break-glass (allowed/denied), each with actor/email/tenant/outcome and a non-secret reason. Tokens and the client secret are never recorded. Audit is best-effort — a sink error never changes a security outcome (a 403 stays a 403, a success stays a success). Events that never resolved a tenant (a tenant-pin rejection) fall back to the default tenant so the security event still lands, with the attempted values in the metadata.

Consequences

  • New public redirect endpoints; one new direct dependency; x/oauth2 promoted to direct.
  • The self-asserted tenant is closed off for OIDC logins (the JWT/password path is unchanged). Email-domain is a secondary, opt-in allowlist, never the pin.
  • Session length is bounded by the JWT TTL; full logout propagation is deferred with the rest of ADR 0055. The per-request is_active reload gives revocation-within-TTL.
  • The identity provider is authoritative for an OIDC user’s roles. On every successful login — for both JIT-created and returning users — the user’s DB user_roles are reconciled to EXACTLY the group→role-mapped set (D5), falling back to [default_role] when the mapping is empty and one is set, or to no roles otherwise. The per-request reload then reads that freshly-written set, so an IdP demotion or deprovisioning takes effect on the next login. The consequence is that a manual DB role grant for an OIDC user is overwritten by the next login: OIDC users’ roles are managed through IdP group membership, not through admin DB grants. A role name (mapped or default_role) that does not exist in the tenant fails the login closed and is audited, rather than silently granting nothing.

Security review — folded in

H1 → D6 (issuer-lock + tid/hd, never email-domain as the pin; D6a adds the domain allowlist strictly on top). H2 → D8 (break-glass scoped to an explicit allowlist, audited). H3 → the role model relies on the seeded role ladder and the per-request permission reload already in place. H4 → D10 (explicit nonce/azp/state/skew/iss checks, not assumed from the library). H5 → the audit decision. Verification order is fixed and fail-closed at every step: signature/audience/issuer → nonce → azp → clock skew → subject → email_verified → tenant pin → email-domain allowlist.

Amendment (2026-09-17): the slice leaves do bind from the environment, and D9’s claim about them is withdrawn

D9 says, of the configuration surface:

the map/slice leaves (role_mappings, tenant_claims, allowed_email_domains, break_glass_emails) are config-file / Helm-values driven.

The decision D9 records is unaffected: the verify path is still keyless, and the client secret still travels only as an environment variable. What is withdrawn is the supporting statement about how those four settings are loaded. It grouped two different things and got one of them wrong.

The slices bind from a single environment variable. viper’s default decoder installs StringToSliceHookFunc(","), so a comma-separated value becomes a list. Measured against LoadServer rather than reasoned about:

LEOFLOW_AUTH_OIDC_SCOPES=openid,email,profile,groups   -> 4 elements
LEOFLOW_AUTH_OIDC_ALLOWED_EMAIL_DOMAINS=a.com,b.com    -> 2 elements
LEOFLOW_AUTH_OIDC_BREAK_GLASS_EMAILS=x@a.com,y@b.com   -> 2 elements
LEOFLOW_AUTH_OIDC_TENANT_CLAIMS=t1:default             -> empty map

The two maps do not, and for a different reason than D9 gives. role_mappings and tenant_claims are tagged mapstructure:"-" and are absent from serverDefaults, so viper never sees them at all. Their keys may contain dots (an IdP group name, a Google Workspace domain), and a dotted key is ambiguous in both the environment and viper’s own key space, so they are read from the YAML config file by a dedicated decoder. That exclusion is deliberate and correct.

“Helm-values driven” was never true of either. The chart ships no server config file and no volume to mount one, so the two maps cannot be set through Helm by any route. That is tracked in #1143, along with the boot gate that now refuses the resulting unsatisfiable configuration instead of letting it reject every login in silence.

This is recorded as an amendment rather than an edit because the original text is what a reader of this ADR believed at the time, and because it did mislead someone: a field report quoted the same claim back to the project, from the matching comment in internal/config/server.go, as the explanation for a problem it does not explain. Both the comment and the configuration reference are corrected; this note keeps the record honest about where the belief came from.

Amendment (2026-09-17): D4’s “pre-existing user row” has no way to exist

D4 says JIT provisioning is off by default and that the default

requires a pre-existing user row (no accidental over-privilege).

The decision stands: creating users implicitly on a first login should be opt-in. What is withdrawn is the sentence describing what the OFF state leaves an operator with. It reads as a supported alternative and there is none.

Nothing an operator can run creates a row that a login would match. D3 makes the identity match (oidc_provider, oidc_subject) and FindUserByOIDCSubject is the only lookup on the login path, so only a row carrying both columns can ever resolve. The only statement that writes them is CreateOIDCUser (internal/storage/queries/users.sql), reached only from jitProvision, which runs only when the flag is on. No API endpoint, CLI command, admin UI or migration writes either column. The user-creation API writes a password user, which leaves both NULL.

So with jit_provisioning: false every first login is denied with no_user_jit_off, and the remedy the sentence implies does not exist. The deployment boots green, the IdP redirect works, the token verifies, the tenant pin passes, and the login is refused at the last step with a generic 403. That is the shape a field report hit on Google Workspace.

What ships now: the Helm chart defaults auth.oidc.jitProvisioning to true, so a chart install is not in this state, and the server logs a boot WARN naming auth.oidc.jit_provisioning whenever it is off. It is a warning and not a boot failure because an operator who wrote the two columns directly with SQL has a working deployment, and a hard gate would break it.

What is left open: D3 already names email as a “verified link key”, but no code links an existing user by verified email today. Implementing that link is the honest way to make the OFF state mean what D4 says it means: an administrator pre-creates the user with the roles they intend, and the first SSO login binds the subject to it. That is a behavior change with its own security argument to make (it lets an IdP account claim an existing account by email), so it is tracked separately rather than folded into a patch release.

Amendment (2026-09-17): a refused login answers the browser, not an API client

D4, D5a, D6 and D6a each describe a rejection as a 403, and the Audit note says “a 403 stays a 403”. The decisions are unaffected: every one of those paths still fails closed, still audits, and still never falls back to a default identity or a default tenant. What changes is the answer on the wire.

Both OIDC routes are reached only by a top-level browser navigation: the user clicks the sign-in control, or the IdP redirects them back. problem+json is the right answer to an API client and the wrong one to a browser, which renders it as a page of raw JSON with no way back to the sign-in page, which is also where the retry and the break-glass form live.

A refused login now answers 302 to /api/v2/auth/login?sso_error=1, and the login page says that sign-on did not complete.

The redirect carries no reason. Withholding the cause from the browser is the posture of this whole path, since it is the same information someone probing the deployment is after. The marker is sso_error=1 and nothing else, the response body is the bare redirect, and no response header carries the cause. The marker is ignored unless an OIDC flow was discovered at boot, so it cannot be used to make a provider: jwt deployment claim a sign-on was rejected.

Where the reason goes is unchanged: the audit row (oidc.login.failure / oidc.tenant_pin_rejected, with the non-secret reason) and, since #1162, a WARN on the control plane’s server log.

Read anywhere in this ADR, “403” now means “rejected, fail-closed, audited”. The credential path (POST /auth/token, including break-glass) is unchanged and still answers problem+json: it is an API call made by the login page’s script, not a navigation.

Amendment (2026-09-17): the Helm gap the first amendment named is now closed

The first amendment on this page, the one that withdrew D9’s claim about the slice leaves, says “‘Helm-values driven’ was never true of either [map],” tracked the gap as #1143, and left it open. #1159 closes it: helm/leoflow/templates/oidc-config.yaml now renders auth.oidc.tenantClaims and auth.oidc.roleMappings into a mounted ConfigMap (the file the two dotted-key maps still need, per D9’s surviving claim that they are config-file-only), and LEOFLOW_CONFIG is pointed at it. A Helm install can set both settings today; see SSO with Google Workspace and helm/leoflow/examples/values-oidc-google.yaml for the resulting values shape.