think
16px
820px

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 ProvisionFromOIDCauthModeFor ✅ closed
POST /auth/oidc/exchange LoginOIDCCodeauthModeFor, before the code is spent ✅ closed
GET /auth/methods AuthModeFor(ctx) ✅ per tenant
POST /auth/step-up/idp/begin BeginStepUpIDPIDPStepUpConfigured(ctx) ✅ closed (c99e02e1)
POST /auth/step-up/idp/finish FinishStepUpIDPIDPStepUpConfigured(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} SetStepUpPolicyIDPStepUpConfigured(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_tokens is in
    the tenant migration set (00084), and requireSCIMToken verifies through the
    tenant-bound context. A tenant-A token presented at tenant B's host is looked up in t_b,
    is not there, and 401s — the same protection resolveTenant documents 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.
  • CachestenantAuthModeCache is keyed by tenant, webauthnFor resolves per tenant, and
    oidcVerified is 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":

  1. 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.
  2. 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 (IDPStepUpConfiguredtrue):

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.