// 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) })