Enforce TOTP second factor on SSO login (#31) #39

Merged
whitlocktech merged 1 commits from bugfix/sso-totp-bypass-31 into main 2026-07-05 03:34:51 +00:00
Member

Closes #31.

Problem

SSO login (finishLogin) minted a full session immediately, ignoring the account's totp_enabled flag. An admin who deliberately enabled 2FA and has a Google/Discord/OIDC identity linked could sign in through the SSO button and get an identical fully-privileged session without presenting their authenticator code — silently downgrading the account to the strength of the external IdP login. The local password flow already gates on needsTotp(); SSO did not, leaving an asymmetric, un-hardened path to the same session.

Fix

Wire SSO through the same staged-TOTP gate the local login uses (the issue's recommended option). Because SSO is a browser redirect flow rather than the local JSON flow, the second factor material is kept httpOnly throughout — the challenge never lands in a URL, browser history, referer, or JS.

  • ssoState.jscreateTotpPending / verifyTotpPending + a short-lived (5m) httpOnly sso_totp cookie. The pending token carries stage:'totp' (session validation rejects it, same marker as the local challenge / #32) and kind:'sso_totp' (scopes it to the SSO endpoint), plus the resolved context (userId, provider, authMethod, returnTo).
  • sso.controller.jsfinishLogin now stages the challenge and redirects to /admin/login?sso_totp=1 instead of creating a session when the account has TOTP on. New finishSsoTotp verifies the code (backoff + bot-scoring on a wrong code, mirroring loginTotp) and only then mints the session.
  • sso.routes.jsPOST /auth/sso/totp behind the same backoffGuard → slowLogin → loginLimiter stack and code validation as the local TOTP endpoint.
  • clientAdminLogin detects ?sso_totp=1, jumps to the existing code-entry step, and completes over fetch via api.ssoLoginTotp / AuthContext.ssoLoginTotp, then navigates to the server-returned returnTo.

Testing

  • 106/106 server tests pass (fail 0), including 12 new ones: TOTP gate stages a challenge with no session; correct code issues a session and clears the cookie; wrong code → 401; missing/expired cookie → 401; and a plain session token or a bare local /login/totp challenge is not accepted by the SSO endpoint.
  • Client builds clean (vite build, exit 0).
  • Not run: a full live OAuth round-trip (needs real IdP credentials — the pending smoke step for the SSO feature overall). Controller logic is exercised directly by unit tests.

🤖 Generated with Claude Code

https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV

Closes #31. ## Problem SSO login (`finishLogin`) minted a full session immediately, ignoring the account's `totp_enabled` flag. An admin who deliberately enabled 2FA **and** has a Google/Discord/OIDC identity linked could sign in through the SSO button and get an identical fully-privileged session **without presenting their authenticator code** — silently downgrading the account to the strength of the external IdP login. The local password flow already gates on `needsTotp()`; SSO did not, leaving an asymmetric, un-hardened path to the same session. ## Fix Wire SSO through the **same staged-TOTP gate** the local login uses (the issue's recommended option). Because SSO is a browser *redirect* flow rather than the local JSON flow, the second factor material is kept `httpOnly` throughout — the challenge never lands in a URL, browser history, referer, or JS. - **`ssoState.js`** — `createTotpPending` / `verifyTotpPending` + a short-lived (`5m`) httpOnly `sso_totp` cookie. The pending token carries `stage:'totp'` (session validation rejects it, same marker as the local challenge / #32) **and** `kind:'sso_totp'` (scopes it to the SSO endpoint), plus the resolved context (userId, provider, authMethod, returnTo). - **`sso.controller.js`** — `finishLogin` now stages the challenge and redirects to `/admin/login?sso_totp=1` instead of creating a session when the account has TOTP on. New `finishSsoTotp` verifies the code (backoff + bot-scoring on a wrong code, mirroring `loginTotp`) and only then mints the session. - **`sso.routes.js`** — `POST /auth/sso/totp` behind the same `backoffGuard → slowLogin → loginLimiter` stack and code validation as the local TOTP endpoint. - **client** — `AdminLogin` detects `?sso_totp=1`, jumps to the existing code-entry step, and completes over fetch via `api.ssoLoginTotp` / `AuthContext.ssoLoginTotp`, then navigates to the server-returned `returnTo`. ## Testing - **106/106 server tests pass** (`fail 0`), including 12 new ones: TOTP gate stages a challenge with no session; correct code issues a session and clears the cookie; wrong code → 401; missing/expired cookie → 401; and a plain session token or a bare local `/login/totp` challenge is **not** accepted by the SSO endpoint. - **Client builds clean** (`vite build`, exit 0). - Not run: a full live OAuth round-trip (needs real IdP credentials — the pending smoke step for the SSO feature overall). Controller logic is exercised directly by unit tests. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
wtclaude added 1 commit 2026-07-05 03:23:15 +00:00
SSO login minted a full session immediately, ignoring the account's
totp_enabled flag — so a 2FA admin with a linked Google/Discord/OIDC
identity could sign in without their authenticator code, silently
downgrading the account to single-factor (the strength of the IdP login).
The local password flow already gates on needsTotp(); SSO did not.

Wire SSO through the same staged-TOTP gate:

- ssoState: createTotpPending/verifyTotpPending + a short-lived httpOnly
  sso_totp cookie. The pending token carries stage:'totp' (session
  validation rejects it) + kind:'sso_totp' (scoped to the SSO endpoint)
  plus the resolved context (userId, provider, authMethod, returnTo).
- sso.controller: finishLogin now stages the challenge and redirects to
  /admin/login?sso_totp=1 instead of creating a session when the account
  has TOTP on. New finishSsoTotp verifies the code (backoff + bot-scoring
  on failure, mirroring loginTotp) and only then mints the session.
- sso.routes: POST /auth/sso/totp behind the same backoff/slow/limiter
  stack and code validation as the local TOTP endpoint.
- client: AdminLogin detects ?sso_totp=1 and completes over fetch via
  api.ssoLoginTotp; the challenge never touches the URL or JS.

Keeps the second factor httpOnly throughout, consistent with the SSO tx
cookie. 12 new tests; full suite 106/106.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
whitlocktech approved these changes 2026-07-05 03:34:45 +00:00
whitlocktech merged commit 2067028070 into main 2026-07-05 03:34:51 +00:00
whitlocktech deleted branch bugfix/sso-totp-bypass-31 2026-07-05 03:34:52 +00:00
Sign in to join this conversation.
No description provided.