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:
@@ -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'),
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user