feat(auth): honor and establish trusted devices on the SSO login paths #108
Reference in New Issue
Block a user
No description provided.
Delete Branch "feat/sso-trusted-device"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What & why
"Trust this device" did nothing if you sign in with Google or Discord.
sso.controllerwent straight fromneedsTotp(user)to staging a pending-TOTP challenge and never calledresolveTrustedDevice, so an SSO user was asked for a code on every sign-in no matter how many times they ticked the box. AndPOST /auth/sso/totpaccepted onlycode, so that step couldn't 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
finishLoginandfinishMobileLoginnow run the same trusted-device check asauth.controller.login, via one shared helper: honor a trust belonging to this user, stamplast_used_at, logauth.login.trusted_device. A store error falls through to the challenge — fail closed to asking for the code.POST /auth/sso/totpgains optionaltrustDevice+deviceName, sets therg_trustcookie, and mirrors the password path's{ trustLimitReached, devices }at the cap (the sign-in still completes). Recovery codes stay password-login only — this step verifies an authenticator code against the staged challenge.How this reaches the Android app — without a secret in a URL
The app opens SSO in a Custom Tab, which shares the system browser's cookie jar, so the
rg_trustcookie set on that TOTP form is presented back on the next app sign-in. That alone makes native SSO skip the code, with no app change.I deliberately did not pass the app's stored trust token into the
/auth/mobile/sso/startURL — the app can't set headers on a tab it doesn't control, and a query param would put a 256-bit secret into query strings,Refererheaders 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/exchangemints aplatform:'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 simply omits it rather than failing a good sign-in.One tick therefore yields two independently-revocable rows (browser + app) — honest, since they're two distinct credentials on one device.
Client
The trust checkbox is no longer hidden on the SSO second step, on both 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.
Companion PRs: Android-app
feat/sso-trusted-device, docsfeat/sso-trusted-device.How it was tested
server/test/ssoTrustedDevice.test.js— trusted device skips the code; a trust bound to a different user is ignored; a store error falls back to the challenge; the cookie is set (and not set) as appropriate; the cap still completes the sign-in; the bridge session is flagged (and not flagged). I reverted the fix and confirmed the key case fails without it, so the test is not vacuous.routes.manifest.jsonis a zero-line diff — no URL moved. Onlyroutes.guards.jsonchanged:+2 handlerson/auth/sso/totpfor the two new validators. Swagger regenerated.POST /auth/sso/totpwithtrustDevicereturned 200, issuedrg_trust, and persisted a realtrusted_devicesrow (platform=web); a subsequent SSO callback carrying that cookie skipped the code and redirected to/admin; a callback with an invalid trust was still bounced to?sso_totp=1. Audit log showsauth.login.trusted_device · provider: google, sso: true.ALTER TABLE … IF NOT EXISTS), confirmed against the existing dev database.Checklist
AI-assisted contributions (required)
Claude Code (Opus 5). I have reviewed and understandevery change, and take responsibility for it. AI-authored commits are
marked with a
Co-Authored-By/Assisted-Bytrailer.License
(GNU GPL v3.0 or later), and I have the right to contribute it.
"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 <noreply@anthropic.com>