feat(auth): honor and establish trusted devices on the SSO login paths
"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:
216
server/test/ssoTrustedDevice.test.js
Normal file
216
server/test/ssoTrustedDevice.test.js
Normal 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)
|
||||
})
|
||||
Reference in New Issue
Block a user