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

@@ -0,0 +1,216 @@
// Point the DB at a closed port BEFORE requiring the controller (its models build
// the pool). Every collaborator is monkeypatched, so no query runs.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, after, beforeEach, afterEach } = require('node:test')
const assert = require('node:assert/strict')
// Trusted devices on the SSO paths. The gap these lock down: SSO used to jump
// straight from needsTotp() to staging a challenge, so a browser the user had
// explicitly trusted was still asked for a code on EVERY Google/Discord sign-in,
// and the SSO second step had no way to establish trust at all.
//
// Covered here:
// - a trusted device (bound to THEM) skips the SSO second factor;
// - a trust bound to a DIFFERENT user is ignored — challenge as usual;
// - a store error falls back to the challenge (fail closed to asking);
// - the mobile bridge (Custom Tab) gets the same skip;
// - POST /auth/sso/totp with trustDevice sets the trust cookie, and flags the
// bridge session so /exchange can mint the app's own token;
// - at the device cap the sign-in still completes, with a trustLimitReached prompt.
const ssoCtrl = require('../src/router/v1/auth/sso.controller')
const users = require('../src/model/users/users.model')
const activity = require('../src/model/activity/activity.model')
const userIdentities = require('../src/model/userIdentities/userIdentities.model')
const sessionService = require('../src/auth/session.service')
const trustedDevices = require('../src/model/trustedDevices/trustedDevices.model')
const mobileBridge = require('../src/model/mobileAuthBridge/mobileAuthBridge.model')
const ssoState = require('../src/auth/ssoState')
const totp = require('../src/utils/totp')
const botScore = require('../src/middleware/botScore')
const loginProtection = require('../src/middleware/loginProtection')
const db = require('../src/utils/db')
after(() => db.close())
function mockRes() {
return {
statusCode: 200,
body: null,
cookies: {},
redirectedTo: null,
status(c) { this.statusCode = c; return this },
json(b) { this.body = b; return this },
cookie(name, val) { this.cookies[name] = val; return this },
clearCookie(name) { this.cookies[name] = undefined; return this },
redirect(to) { this.redirectedTo = to; return this },
}
}
const TOTP_USER = { id: 5, username: 'gwen', role: 'player', status: 'active', totp_enabled: 1, totp_secret: 'S' }
let sessionsCreated
const orig = {}
beforeEach(() => {
sessionsCreated = []
for (const [mod, name] of [
[users, 'getRawById'], [users, 'getById'], [users, 'recordLogin'],
[activity, 'log'],
[userIdentities, 'findByProviderSubject'],
[sessionService, 'createSession'],
[sessionService, 'resolveTrustedDevice'], [sessionService, 'honorTrustedDevice'],
[sessionService, 'trustDeviceCapReached'], [sessionService, 'mintTrustToken'], [sessionService, 'sessionMeta'],
[trustedDevices, 'store'], [trustedDevices, 'listActiveForUser'],
[mobileBridge, 'getSession'], [mobileBridge, 'markTrustRequested'], [mobileBridge, 'issueAuthCode'],
[ssoState, 'createTotpPending'], [ssoState, 'verifyTotpPending'],
[totp, 'verifyCode'],
[botScore, 'recordLoginFailure'], [loginProtection, 'recordFailure'], [loginProtection, 'recordSuccess'],
]) {
orig[name] = orig[name] || { mod, val: mod[name] }
}
users.recordLogin = async () => {}
activity.log = async () => {}
botScore.recordLoginFailure = () => {}
loginProtection.recordFailure = () => {}
loginProtection.recordSuccess = () => {}
sessionService.createSession = (user, authMethod) => {
sessionsCreated.push({ user, authMethod })
return { token: 'session-token' }
}
sessionService.honorTrustedDevice = async () => true
sessionService.sessionMeta = () => ({ deviceHash: 'dh', userAgent: 'UA' })
sessionService.trustDeviceCapReached = async () => false
sessionService.mintTrustToken = () => ({
trustToken: 'raw-trust', trustHash: 'hash', deviceHash: 'dh', userAgent: 'UA', expiresAt: new Date(Date.now() + 1e6),
})
trustedDevices.store = async () => 1
ssoState.createTotpPending = () => 'pending-jwt'
userIdentities.findByProviderSubject = async () => ({ user_id: 5 })
users.getById = async () => TOTP_USER
})
afterEach(() => {
for (const key of Object.keys(orig)) { orig[key].mod[key] = orig[key].val; delete orig[key] }
})
const baseReq = (body = {}) => ({ body, ip: '10.0.0.1', headers: {}, cookies: {} })
const tx = { returnTo: '/account' }
const profile = { subject: 'sub-1', email: 'g@example.test' }
// ── finishLogin(): trusted device skips the SSO second factor ──────────────
test('sso login: a TOTP user on a device trusted by THEM skips the code', async () => {
users.getRawById = async () => TOTP_USER
sessionService.resolveTrustedDevice = async () => ({ id: 11, user_id: 5 })
let honored = null
sessionService.honorTrustedDevice = async (id) => { honored = id }
const res = mockRes()
await ssoCtrl.finishLogin(baseReq(), res, 'google', 'sso', tx, profile)
assert.equal(sessionsCreated.length, 1, 'session issued without a TOTP bounce')
assert.equal(honored, 11, 'the trusted device was stamped as used')
assert.ok(!String(res.redirectedTo).includes('sso_totp'), `did not bounce to the code form (got ${res.redirectedTo})`)
})
test('sso login: a trust bound to a DIFFERENT user is ignored (code still required)', async () => {
sessionService.resolveTrustedDevice = async () => ({ id: 11, user_id: 999 })
const res = mockRes()
await ssoCtrl.finishLogin(baseReq(), res, 'google', 'sso', tx, profile)
assert.match(String(res.redirectedTo), /sso_totp=1/)
assert.equal(sessionsCreated.length, 0)
})
test('sso login: a trusted-device lookup error falls back to the code (fail closed)', async () => {
sessionService.resolveTrustedDevice = async () => { throw new Error('store down') }
const res = mockRes()
await ssoCtrl.finishLogin(baseReq(), res, 'google', 'sso', tx, profile)
assert.match(String(res.redirectedTo), /sso_totp=1/)
assert.equal(sessionsCreated.length, 0)
})
// ── finishSsoTotp(): the second step can now establish trust ───────────────
test('sso totp: trustDevice sets the trust cookie and completes the sign-in', async () => {
ssoState.verifyTotpPending = () => ({ id: 5, provider: 'google', authMethod: 'sso', returnTo: '/account' })
users.getRawById = async () => TOTP_USER
totp.verifyCode = () => true
const res = mockRes()
await ssoCtrl.finishSsoTotp(baseReq({ code: '123456', trustDevice: true, deviceName: 'Kitchen laptop' }), res)
assert.equal(res.statusCode, 200)
assert.equal(res.cookies.rg_trust, 'raw-trust', 'trust cookie issued')
assert.equal(res.body.user.id, 5)
assert.equal(res.body.trustLimitReached, undefined)
})
test('sso totp: without trustDevice no trust cookie is set', async () => {
ssoState.verifyTotpPending = () => ({ id: 5, provider: 'google', authMethod: 'sso', returnTo: '/account' })
users.getRawById = async () => TOTP_USER
totp.verifyCode = () => true
const res = mockRes()
await ssoCtrl.finishSsoTotp(baseReq({ code: '123456' }), res)
assert.equal(res.statusCode, 200)
assert.equal(res.cookies.rg_trust, undefined)
})
test('sso totp: at the device cap the sign-in still succeeds, with a trustLimitReached prompt', async () => {
ssoState.verifyTotpPending = () => ({ id: 5, provider: 'google', authMethod: 'sso', returnTo: '/account' })
users.getRawById = async () => TOTP_USER
totp.verifyCode = () => true
sessionService.trustDeviceCapReached = async () => true
trustedDevices.listActiveForUser = async () => [{ id: 1, platform: 'web' }]
const res = mockRes()
await ssoCtrl.finishSsoTotp(baseReq({ code: '123456', trustDevice: true }), res)
assert.equal(res.statusCode, 200)
assert.equal(res.body.trustLimitReached, true)
assert.equal(res.cookies.rg_trust, undefined, 'no cookie when the cap refused the trust')
assert.equal(sessionsCreated.length, 1, 'the session is still issued')
})
// ── mobile bridge: the Custom Tab gets the same treatment ──────────────────
test('sso totp (mobile bridge): trustDevice flags the session so /exchange can mint the app token', async () => {
ssoState.verifyTotpPending = () => ({ id: 5, provider: 'google', authMethod: 'sso', mobileSessionId: 'sess-1' })
users.getRawById = async () => TOTP_USER
totp.verifyCode = () => true
mobileBridge.getSession = async () => ({
session_id: 'sess-1', status: 'pending', expires_at: new Date(Date.now() + 60000),
redirect_uri: 'runicgateway://auth/callback', state: 'st',
})
mobileBridge.issueAuthCode = async () => ({ code: 'authcode' })
let flagged = null
mobileBridge.markTrustRequested = async (id) => { flagged = id; return true }
const res = mockRes()
await ssoCtrl.finishSsoTotp(baseReq({ code: '123456', trustDevice: true }), res)
assert.equal(flagged, 'sess-1', 'bridge session flagged for the app-side trust token')
assert.equal(res.cookies.rg_trust, 'raw-trust', 'the Custom Tab browser is trusted too')
assert.match(String(res.body.redirect), /^runicgateway:\/\/auth\/callback/)
})
test('sso totp (mobile bridge): without trustDevice the session is not flagged', async () => {
ssoState.verifyTotpPending = () => ({ id: 5, provider: 'google', authMethod: 'sso', mobileSessionId: 'sess-1' })
users.getRawById = async () => TOTP_USER
totp.verifyCode = () => true
mobileBridge.getSession = async () => ({
session_id: 'sess-1', status: 'pending', expires_at: new Date(Date.now() + 60000),
redirect_uri: 'runicgateway://auth/callback', state: 'st',
})
mobileBridge.issueAuthCode = async () => ({ code: 'authcode' })
let flagged = null
mobileBridge.markTrustRequested = async (id) => { flagged = id; return true }
const res = mockRes()
await ssoCtrl.finishSsoTotp(baseReq({ code: '123456' }), res)
assert.equal(flagged, null)
assert.equal(res.cookies.rg_trust, undefined)
})