feat(auth): honor and establish trusted devices on the SSO login paths
All checks were successful
PR Checks / bot-install (pull_request) Successful in 19s
PR Checks / client-build (pull_request) Successful in 25s
PR Checks / server-tests (pull_request) Successful in 9m21s

"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>
This commit is contained in:
2026-07-28 01:01:12 -05:00
parent f6611231c4
commit 620781b7bc
14 changed files with 419 additions and 44 deletions

View File

@@ -22,6 +22,7 @@ const settings = require('../../../model/settings/settings.model')
const sessionService = require('../../../auth/session.service')
const ssoState = require('../../../auth/ssoState')
const ssoController = require('./sso.controller')
const { establishTrust } = require('./trustDevice.helper')
const log = require('../../../utils/logger')('auth-mobile-sso')
@@ -163,12 +164,36 @@ async function exchange(req, res) {
expiresAt: out.refreshExpiresAt,
})
await activity.log({ req, userId: user.id, action: 'auth.mobile.login', detail: { sso: sess.provider } })
// The user ticked "trust this device" on the Custom Tab TOTP form. That already
// set the browser's rg_trust cookie (which is what lets the NEXT Custom Tab SSO
// sign-in skip the code); mint the app its OWN trust token here so a native
// password login on the same device skips the code too. Minting at this point
// — an authenticated app→server call — is deliberate: the token reaches the app
// in a JSON body and never travels in the deep-link URL or sits in the bridge
// row. Best-effort: a device at the trust cap just gets no token, never a failed
// sign-in, so this can't turn a good login into an error.
let trustToken = null
if (sess.trust_device) {
try {
const trust = await establishTrust(req, user, {
platform: 'mobile',
deviceName: req.body.device_name || null,
})
if (trust.ok) trustToken = trust.trustToken
else if (trust.capReached) log.info('mobile sso: trust refused, device cap reached', { id: user.id })
} catch (err) {
log.error('mobile sso: could not establish trust (continuing, sign-in already succeeded)', err)
}
}
log.info('mobile sso exchange success', { id: user.id, provider: sess.provider, ip: req.ip })
return res.json({
accessToken: out.accessToken,
refreshToken: out.refreshToken,
expiresIn: out.expiresIn,
user: { id: user.id, username: user.username, role: user.role },
...(trustToken ? { trustToken } : {}),
})
} catch (err) {
log.error('mobile sso exchange', err)