diff --git a/client/src/api/client.js b/client/src/api/client.js index 2942301..6f522a6 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -71,8 +71,10 @@ export const api = { resetPassword: (token, password) => req(`/auth/password/reset/${encodeURIComponent(token)}`, { method: 'POST', body: { password } }), // 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 } }), + // the callback, so only the code is sent). `extra` carries the trustDevice/ + // deviceName opt-in, same as the password path. Returns { user, returnTo } — plus + // { trustLimitReached, devices } when trust was asked for but the cap is reached. + ssoLoginTotp: (code, extra = {}) => req('/auth/sso/totp', { method: 'POST', body: { code, ...extra } }), logout: () => req('/auth/logout', { method: 'POST' }), // Public SSO provider discovery — drives the login-page provider buttons. authProviders: () => req('/auth/providers'), diff --git a/client/src/contexts/AuthContext.jsx b/client/src/contexts/AuthContext.jsx index c92f164..5b6dce6 100644 --- a/client/src/contexts/AuthContext.jsx +++ b/client/src/contexts/AuthContext.jsx @@ -49,9 +49,11 @@ export function AuthProvider({ children }) { }, []) // 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) + // an httpOnly cookie, so only the code is sent. `extra` carries the trustDevice/ + // deviceName opt-in. Returns the full payload ({ user, returnTo, + // trustLimitReached?, devices? }) so the caller can handle the device-cap prompt. + const ssoLoginTotp = useCallback(async (code, extra) => { + const data = await api.ssoLoginTotp(code, extra) setUser(data.user) return data }, []) diff --git a/client/src/routes/admin/AdminLogin.jsx b/client/src/routes/admin/AdminLogin.jsx index bea6c35..bd71039 100644 --- a/client/src/routes/admin/AdminLogin.jsx +++ b/client/src/routes/admin/AdminLogin.jsx @@ -123,8 +123,16 @@ export default function AdminLogin() { setBusy(true) try { if (ssoTotp) { - const { returnTo } = await ssoLoginTotp(code) - navigate(returnTo || '/admin', { replace: true }) + // Trust works on the SSO second factor exactly as it does on the password + // one — the IdP already proved the first factor. + const data = await ssoLoginTotp(code.trim(), { trustDevice }) + const to = data.returnTo || '/admin' + if (data.trustLimitReached) { + setTrustLimit({ devices: data.devices || [], dest: to }) + setBusy(false) + return + } + navigate(to, { replace: true }) } else { const entered = code.trim() const data = await loginTotp(challenge, useRecovery ? '' : entered, { @@ -251,12 +259,14 @@ export default function AdminLogin() { {useRecovery ? 'Enter one of your saved single-use recovery codes.' : 'Enter the code from your authenticator app.'} - {!ssoTotp && ( - - )} + {/* Offered on the SSO second factor too — the trust is on the device, + not on how the first factor was proved. */} + + {/* Recovery codes remain password-login only: the SSO second step + verifies an authenticator code against the staged challenge. */} {!ssoTotp && (