From 620781b7bcb6e2e47bfd40c964b5b6922eee3bef Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 28 Jul 2026 01:01:12 -0500 Subject: [PATCH] feat(auth): honor and establish trusted devices on the SSO login paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Trust this device" did nothing for anyone who signs in with Google or Discord. sso.controller went straight from needsTotp(user) to staging a pending-TOTP challenge and never consulted resolveTrustedDevice, so an SSO user was asked for a code on EVERY sign-in no matter how many times they had ticked the box — and POST /auth/sso/totp accepted only `code`, so that step could not establish a trust either. The password paths (web + native) were unaffected and already worked; this closes the gap for SSO, on the website AND in the Android app. Server: - finishLogin and finishMobileLogin now run the same trusted-device check as auth.controller.login, via one shared helper: honor a trust that belongs to THIS user, stamp last_used_at, log auth.login.trusted_device. A store error falls through to the challenge — fail closed to asking for the code. - POST /auth/sso/totp gains optional trustDevice + deviceName, sets the rg_trust cookie, and mirrors the password path's { trustLimitReached, devices } response at the cap (the sign-in still completes). Recovery codes stay password-only. Android coverage, without leaking a secret into a URL: - The app opens SSO in a Custom Tab, which shares the system browser's cookie jar, so the rg_trust cookie set on that TOTP form is presented back on the next app sign-in. That alone makes native SSO skip the code. Passing the app's token into the start URL was rejected — it would put a 256-bit secret in query strings, Referer headers and access logs. - To also cover the app's NATIVE password login, ticking the box sets mobile_auth_sessions.trust_device (a boolean; never the token), and /auth/mobile/sso/exchange mints a platform:'mobile' trust and returns { trustToken }. Minting there keeps the raw token on an authenticated app→server call, out of the deep link and out of the bridge row. Best-effort: at the cap the response just omits it rather than failing a good sign-in. Client: the trust checkbox is no longer hidden on the SSO second step, on both the admin and player login screens. On the mobile bridge the deep-link redirect takes priority over the cap prompt — the sign-in succeeded and the link is single-use, so stalling there would strand the app. Tests: 8 new cases in server/test/ssoTrustedDevice.test.js (verified to fail against the pre-fix controller). Full suites green — server 445, client 43 — and routes.manifest.json is a zero-line diff: no URL moved, only +2 handlers on /auth/sso/totp in routes.guards.json for the two new validators. Swagger regenerated. Verified live against the running server and real MariaDB: the TOTP step issues rg_trust and persists the row, a subsequent SSO callback carrying it skips the code, and an invalid trust is still challenged. Co-Authored-By: Claude --- client/src/api/client.js | 6 +- client/src/contexts/AuthContext.jsx | 8 +- client/src/routes/admin/AdminLogin.jsx | 26 ++- client/src/routes/player/PlayerLogin.jsx | 38 ++- server/db/schema.sql | 9 + server/routes.guards.json | 2 +- .../mobileAuthBridge/mobileAuthBridge.db.js | 15 ++ .../mobileAuthBridge.model.js | 9 + .../router/v1/auth/mobileSso.controller.js | 25 ++ server/src/router/v1/auth/mobileSso.routes.js | 4 +- server/src/router/v1/auth/sso.controller.js | 71 +++++- server/src/router/v1/auth/sso.routes.js | 8 +- server/swagger/swagger-output.json | 26 ++- server/test/ssoTrustedDevice.test.js | 216 ++++++++++++++++++ 14 files changed, 419 insertions(+), 44 deletions(-) create mode 100644 server/test/ssoTrustedDevice.test.js 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 && (