Enforce TOTP second factor on SSO login (#31)

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
This commit is contained in:
2026-07-04 22:17:35 -05:00
parent 15cf8ea286
commit 03e62b56ad
8 changed files with 281 additions and 11 deletions

View File

@@ -46,6 +46,9 @@ export const api = {
req('/auth/login', { method: 'POST', body: { username, password, ...extra } }),
loginTotp: (challenge, code) =>
req('/auth/login/totp', { method: 'POST', body: { challenge, code } }),
// Second factor for an SSO login (challenge is held in an httpOnly cookie set by
// the callback, so only the code is sent). Returns { user, returnTo }.
ssoLoginTotp: (code) => req('/auth/sso/totp', { method: 'POST', body: { code } }),
logout: () => req('/auth/logout', { method: 'POST' }),
// Public SSO provider discovery — drives the login-page provider buttons.
authProviders: () => req('/auth/providers'),

View File

@@ -37,6 +37,14 @@ export function AuthProvider({ children }) {
return data.user
}, [])
// Step 2 for SSO logins whose account has 2FA on. The pending challenge lives in
// an httpOnly cookie, so only the code is sent. Returns { user, returnTo }.
const ssoLoginTotp = useCallback(async (code) => {
const data = await api.ssoLoginTotp(code)
setUser(data.user)
return data
}, [])
const logout = useCallback(async () => {
try {
await api.logout()
@@ -46,7 +54,7 @@ export function AuthProvider({ children }) {
}, [])
return (
<AuthContext.Provider value={{ user, loading, login, loginTotp, logout, refresh }}>
<AuthContext.Provider value={{ user, loading, login, loginTotp, ssoLoginTotp, logout, refresh }}>
{children}
</AuthContext.Provider>
)

View File

@@ -31,7 +31,7 @@ const honeypotStyle = {
}
export default function AdminLogin() {
const { user, login, loginTotp } = useAuth()
const { user, login, loginTotp, ssoLoginTotp } = useAuth()
const navigate = useNavigate()
const location = useLocation()
const dest = location.state?.from?.pathname || '/admin'
@@ -42,10 +42,12 @@ export default function AdminLogin() {
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
// Two-factor step state.
// Two-factor step state. `ssoTotp` marks the SSO variant: the challenge lives in
// an httpOnly cookie (not React state), so the code posts to a different endpoint.
const [stage, setStage] = useState('creds') // 'creds' | 'totp'
const [challenge, setChallenge] = useState('')
const [code, setCode] = useState('')
const [ssoTotp, setSsoTotp] = useState(false)
// SSO providers to offer (empty if none configured) + any error the callback
// bounced us back with (?sso_error=...).
@@ -57,6 +59,16 @@ export default function AdminLogin() {
if (user) navigate(dest, { replace: true })
}, [user, dest, navigate])
// The SSO callback bounces 2FA accounts back here with ?sso_totp=1 after the IdP
// step: it has staged an httpOnly TOTP challenge and needs the authenticator code
// before it will issue a session. Jump straight to the code step.
useEffect(() => {
if (new URLSearchParams(location.search).get('sso_totp')) {
setStage('totp')
setSsoTotp(true)
}
}, [location.search])
// Load enabled SSO providers for the buttons. Failure is non-fatal — the page
// still works with password login and simply shows no provider buttons.
useEffect(() => {
@@ -101,16 +113,25 @@ export default function AdminLogin() {
setError('')
setBusy(true)
try {
await loginTotp(challenge, code)
navigate(dest, { replace: true })
if (ssoTotp) {
const { returnTo } = await ssoLoginTotp(code)
navigate(returnTo || '/admin', { replace: true })
} else {
await loginTotp(challenge, code)
navigate(dest, { replace: true })
}
} catch (err) {
const expired = err.status === 401 && /expired/i.test(err.message)
setError(
err.status === 401 && /expired/i.test(err.message)
expired
? 'Your verification session expired. Please sign in again.'
: 'Invalid verification code.',
)
setBusy(false)
if (err.status === 401 && /expired/i.test(err.message)) setStage('creds')
if (expired) {
setStage('creds')
setSsoTotp(false)
}
}
}