The OIDC door sweep
Verdict: NO third door. The family is closed.
Branch: sweep/third-oidc-door (test only — no behaviour change)
Base: c99e02e1
Date: 2026-08-20
Two doors were found and fixed: login (9a42b6f5) and step-up-via-IdP (c99e02e1). This is the
sweep for a third, before the tag.
What was hunted
Anything that reads s.verifier, s.exchanger, AUTH_MODE, OIDC_* config or IDPStepUp*
state and answers without consulting authModeFor(ctx) — and the surfaces that expose them,
because a route that offers a method is as wrong as one that starts it.
Every OIDC entry point, classified
| Entry point | Gate | Verdict |
|---|---|---|
POST /auth/oidc-login |
ProvisionFromOIDC → authModeFor |
✅ closed |
POST /auth/oidc/exchange |
LoginOIDCCode → authModeFor, before the code is spent |
✅ closed |
GET /auth/methods |
AuthModeFor(ctx) |
✅ per tenant |
POST /auth/step-up/idp/begin |
BeginStepUpIDP → IDPStepUpConfigured(ctx) |
✅ closed (c99e02e1) |
POST /auth/step-up/idp/finish |
FinishStepUpIDP → IDPStepUpConfigured(ctx) |
✅ closed (c99e02e1) |
GET /auth/step-up/methods |
filters StepUpIDP on IDPStepUpConfigured(ctx) |
✅ per tenant |
GET /admin/step-up/policies |
reports idp_configured per tenant |
✅ per tenant |
PUT /admin/step-up/policies/{purpose} |
SetStepUpPolicy → IDPStepUpConfigured(ctx) |
✅ closed |
PUT /admin/users/{id}/sso-claimable |
none — see below | ⚪ inert, deliberately left |
Exhaustive on the predicate, not sampled: every read of .verifier/.exchanger in the tree
is inside one of the gated functions above, authModeFor itself, or the OIDC adapter's own
internals. Every read of cfg.AuthMode*/cfg.OIDC*/cfg.AuthBreakGlass/cfg.BootstrapAdminEmail
outside the config package is either boot-time wiring in wire.go or inside an already-gated
handler.
Surfaces checked and cleared
- SCIM — the plausible candidate: IdP-driven provisioning into a tenant.
scim_tokensis in
the tenant migration set (00084), andrequireSCIMTokenverifies through the
tenant-bound context. A tenant-A token presented at tenant B's host is looked up int_b,
is not there, and 401s — the same protectionresolveTenantdocuments for sessions. Not a door. - Operator console — the place the last bug hid, because an unlisted path answers 200 with
HTML rather than failing. Its whole proxy allowlist contains exactly three auth-adjacent
routes: its own/auth/login,/auth/totp,/auth/logout, plus the/tenants/{id}/auth
trio I added. No OIDC surface at all. - Web SPA and mobile — both obtain OIDC config only from
GET /auth/methods, which is per
tenant. Mobile additionally exchanges through/auth/oidc/exchange, which is gated. Two
layers, both per tenant; a hard-coded client would still be refused at the exchange. - Caches —
tenantAuthModeCacheis keyed by tenant,webauthnForresolves per tenant, and
oidcVerifiedis keyed by user id (UUIDv7, globally unique, so no cross-tenant collision).
The one thing left deliberately open
PUT /admin/users/{id}/sso-claimable sets a flag meaning "when this person first arrives
through SSO, adopt THIS account rather than making a duplicate". It is not gated.
It is not a door: it grants nothing, starts nothing, and its only consumer is
LinkOIDCIdentityByEmail, reached only from ProvisionFromOIDC, which is gated. On a tenant
with SSO off the flag simply never fires.
Gating it would break a legitimate workflow the code already documents — an admin pre-creating
people with roles and folder access before an operator turns SSO on. Blocking preparation
because the thing being prepared for is not enabled yet is the wrong trade.
⚠️ Worth knowing rather than fixing: sso_claimable lets adoption happen without
email_verified. Flags set while a tenant is local-only all become live at once if SSO is later
switched on. Same family as "a deployment AUTH_MODE change silently overrides a tenant's own
setting", which bit production this morning.
What ships on this branch
No behaviour change. One test:
TestEveryOIDCDoorIsClosedForATenantWithSSOOff.
It asserts the list rather than a single door, because both previous gaps were not a gate
written wrongly but an entry point nobody remembered was one. A fourth door fails this test only
if someone adds it to the list — which is the point: the list is the checklist, and a reviewer
looking at a new OIDC route has one obvious place to notice it belongs.
Two properties beyond "everything is refused":
- Each door is also asserted OPEN for the tenant that kept SSO, in the same process against
the same verifier. Without that, the test would pass just as happily on a build where SSO is
broken for everybody. - The refusal message is checked, not just the error. A door failing for an unrelated reason
starts working the moment that reason goes away.
Property 2 earned its keep immediately. With the step-up gate neutered, FinishStepUpIDP still
errored — but with idp_state_unknown, not the tenant refusal. A test asserting only "it
errored" would have called that door shut when it was merely empty.
Verified by neutering, both halves
Neuter the step-up gate (IDPStepUpConfigured → true):
step-up begin: SUCCEEDED for a tenant with SSO off — this is a door
step-up finish: refused … but with "idp_state_unknown", not the tenant gate
step-up policy write: SUCCEEDED for a tenant with SSO off — this is a door
the step-up screen would still offer an IdP check to a tenant that opted out
Neuter the login gate (authModeFor ignoring the tenant's mode) — all five doors plus both
discovery surfaces fire:
raw-token login: SUCCEEDED for a tenant with SSO off — this is a door
browser code exchange: refused … but with "exchange_failed", not the tenant gate
step-up begin: SUCCEEDED …
step-up finish: refused … but with "idp_state_unknown" …
step-up policy write: SUCCEEDED …
GET /auth/methods would still advertise SSO to a tenant that opted out
the step-up screen would still offer an IdP check to a tenant that opted out
Restored, green: go vet clean, ./internal/auth/... ./internal/httpapi/ ./internal/operator/
all pass.
🔴 This test skips without TEST_DATABASE_URL, and CI sets nothing — see
docs/plans/2026-08-20-ci-has-no-test-job.md. Until CI has a test job, this checklist is
enforced only by whoever runs it locally.