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

@@ -26,6 +26,7 @@ const usernamePolicy = require('../../../auth/usernamePolicy')
const botScore = require('../../../middleware/botScore')
const loginProtection = require('../../../middleware/loginProtection')
const { needsTotp } = require('./auth.controller')
const { establishTrust } = require('./trustDevice.helper')
const log = require('../../../utils/logger')('sso')
@@ -226,6 +227,29 @@ async function provisionSsoPlayer(req, providerId, profile) {
return null
}
// Trusted-device skip for the SSO paths — the exact analogue of the check in
// auth.controller.login, and the reason SSO used to demand a code on every single
// sign-in even from a browser the user had explicitly trusted.
//
// The first factor here is the IdP authentication that just succeeded, so skipping
// the SECOND factor on a device the user deliberately trusted is the same posture
// as the password path. The trust must belong to THIS user (a trust token is
// scoped to the account that minted it), and any store hiccup falls through to the
// normal TOTP challenge — fail closed to asking for the code.
async function trustedDeviceSkips(req, user, providerId) {
try {
const device = await sessionService.resolveTrustedDevice(req)
if (!device || device.user_id !== user.id) return false
await sessionService.honorTrustedDevice(device.id)
await activity.log({ req, userId: user.id, action: 'auth.login.trusted_device', detail: { provider: providerId, sso: true } })
log.info('sso login via trusted device (TOTP skipped)', { provider: providerId, id: user.id, ip: req.ip })
return true
} catch (err) {
log.error('sso trusted-device check failed; falling back to TOTP', err)
return false
}
}
// SSO login. Normally link-only: a login succeeds only if the external identity
// is already linked. The one setting-gated relaxation is auto-provisioning a
// player when player_registration ∈ {sso, both} (see provisionSsoPlayer).
@@ -257,10 +281,11 @@ async function finishLogin(req, res, providerId, kind, tx, profile) {
const authMethod = sessionService.AUTH_METHODS.includes(kind) ? kind : 'sso'
// 2FA parity with the local login (auth.controller): if the account has TOTP
// enabled, an SSO sign-in must NOT bypass the second factor. Stage a signed,
// httpOnly challenge and route the browser through the TOTP form instead of
// minting a session here. See issue #31.
if (needsTotp(user)) {
// enabled, an SSO sign-in must NOT bypass the second factor — unless this browser
// is a trusted device, which skips the second factor exactly as it does for a
// password login. Otherwise stage a signed, httpOnly challenge and route the
// browser through the TOTP form instead of minting a session here. See issue #31.
if (needsTotp(user) && !(await trustedDeviceSkips(req, user, providerId))) {
const pending = ssoState.createTotpPending({
userId: user.id,
provider: providerId,
@@ -381,10 +406,17 @@ async function finishMobileLogin(req, res, providerId, kind, tx, profile) {
const authMethod = sessionService.AUTH_METHODS.includes(kind) ? kind : 'sso'
if (needsTotp(user)) {
// Same second-factor gate as web: stage a signed pending-TOTP cookie (now
// carrying the bridge session) and route the Custom Tab through the player
// TOTP form. finishSsoTotp completes the mobile flow on a correct code.
// Same second-factor gate as web, including the trusted-device skip. This request
// is the IdP redirect landing in the app's Custom Tab, which shares the system
// browser's cookie jar — so an rg_trust cookie set by a previous SSO sign-in from
// this app IS presented here, and the app gets the same "don't ask me again"
// behaviour as the website without having to inject a header into a tab it does
// not control. (Putting the token in the start URL instead would leak a secret
// into query strings and logs.)
if (needsTotp(user) && !(await trustedDeviceSkips(req, user, providerId))) {
// Stage a signed pending-TOTP cookie (now carrying the bridge session) and route
// the Custom Tab through the player TOTP form. finishSsoTotp completes the
// mobile flow on a correct code.
const pending = ssoState.createTotpPending({
userId: user.id,
provider: providerId,
@@ -432,6 +464,19 @@ async function finishSsoTotp(req, res) {
res.clearCookie(ssoState.TOTP_COOKIE, token.cookieOptions(req))
loginProtection.recordSuccess(req.ip)
// Optionally remember this browser, exactly as the password path does. On the
// mobile flow this browser IS the Custom Tab, so the cookie set here is what
// lets the NEXT app sign-in skip the code.
let trustLimit = null
if (req.body.trustDevice) {
const result = await establishTrust(req, user, {
platform: 'web',
deviceName: req.body.deviceName || null,
})
if (result.ok) token.setTrustCookie(req, res, result.trustToken)
else if (result.capReached) trustLimit = result.devices
}
// Mobile SSO bridge: instead of a web session, mint the one-time auth code and
// return a deep link for the app to redeem. The second factor is now complete,
// so the code is issued no earlier than an ordinary web session would be.
@@ -440,11 +485,18 @@ async function finishSsoTotp(req, res) {
if (!sess || sess.status !== 'pending' || new Date(sess.expires_at).getTime() <= Date.now()) {
return res.status(401).json({ message: 'Your sign-in session expired. Please sign in again from the app.' })
}
// Record the user's choice on the bridge session (a boolean — never the token)
// so /auth/mobile/sso/exchange can mint the APP's own trust token and hand it
// back over that authenticated app→server call. The token therefore never
// travels in the deep-link URL.
if (req.body.trustDevice && !trustLimit) {
await mobileBridge.markTrustRequested(sess.session_id)
}
const link = await mintMobileAuthLink(req, sess, user, pending.provider, true)
if (!link) {
return res.status(409).json({ message: 'This sign-in session was already used. Please sign in again from the app.' })
}
return res.json({ redirect: link })
return res.json({ redirect: link, ...(trustLimit ? { trustLimitReached: true, devices: trustLimit } : {}) })
}
const authMethod = sessionService.AUTH_METHODS.includes(pending.authMethod) ? pending.authMethod : 'sso'
@@ -456,6 +508,7 @@ async function finishSsoTotp(req, res) {
return res.json({
user: { id: user.id, username: user.username, role: user.role },
returnTo: sanitizeReturn(pending.returnTo) || homePath(portalFor(pending.returnTo)),
...(trustLimit ? { trustLimitReached: true, devices: trustLimit } : {}),
})
} catch (err) {
log.error('sso totp error', err)