"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>
217 lines
9.9 KiB
JavaScript
217 lines
9.9 KiB
JavaScript
// 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)
|
|
})
|