// Mobile SSO authorization bridge — controller/integration tests. Models are // stubbed (mutable object exports), so these are DB-free and exercise the bridge // start/exchange handlers plus the mode:'mobile' branches added to sso.controller. process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret' process.env.SECRET_ENC_KEY = process.env.SECRET_ENC_KEY || 'unit-test-enc-key' process.env.DB_HOST = '127.0.0.1' process.env.DB_PORT = '59999' const { test, beforeEach, after } = require('node:test') const assert = require('node:assert/strict') const mobileSso = require('../src/router/v1/auth/mobileSso.controller') const ssoCtrl = require('../src/router/v1/auth/sso.controller') const ssoState = require('../src/auth/ssoState') const token = require('../src/auth/token') const bridge = require('../src/model/mobileAuthBridge/mobileAuthBridge.model') const mobileSessions = require('../src/model/mobileSessions/mobileSessions.model') const users = require('../src/model/users/users.model') const activity = require('../src/model/activity/activity.model') const authProviders = require('../src/model/authProviders/authProviders.model') const userIdentities = require('../src/model/userIdentities/userIdentities.model') const settings = require('../src/model/settings/settings.model') const registry = require('../src/auth/providers/registry') const totp = require('../src/utils/totp') const db = require('../src/utils/db') after(() => db.close()) const CALLBACK = 'runicgateway://auth/callback' const PROFILE = { subject: 'sub-1', email: 'alice@example.com', name: 'Alice' } const SESSION = { session_id: 'sess-1', status: 'pending', provider: 'google', redirect_uri: CALLBACK, state: 'st-abc', code_challenge: ssoState.codeChallengeFor('verifier-xyz'), expires_at: new Date(Date.now() + 5 * 60 * 1000), } let logged beforeEach(() => { logged = [] activity.log = async (e) => { logged.push(e) } authProviders.getWithSecret = async () => ({ id: 'google', kind: 'google', enabled: 1, client_id: 'c', client_secret_enc: 'e' }) registry.instantiate = () => ({ handleCallback: async () => ({ ...PROFILE }) }) registry.validateConfig = () => ({ valid: true }) userIdentities.findByProviderSubject = async () => ({ user_id: 7 }) settings.getRegistrationMode = async () => 'disabled' users.getById = async (id) => ({ id, username: 'alice', role: 'player' }) users.recordLogin = async () => {} mobileSessions.store = async () => 1 bridge.getSession = async () => ({ ...SESSION }) bridge.issueAuthCode = async () => ({ code: 'RAWCODE' }) bridge.startSession = async () => ({ sessionId: 'sess-1' }) bridge.findRedeemableCode = async () => ({ user_id: 7, session_id: 'sess-1' }) bridge.consumeCode = async () => true bridge.finishSession = async () => 1 }) function mockRes() { return { statusCode: 200, redirectedTo: null, body: null, cookies: {}, cleared: [], status(c) { this.statusCode = c; return this }, json(b) { this.body = b; return this }, redirect(u) { this.redirectedTo = u; return this }, cookie(n, v) { this.cookies[n] = v; return this }, clearCookie(n) { this.cleared.push(n); return this }, } } // ── /start ───────────────────────────────────────────────────────────────── test('start: an un-allowlisted redirect_uri is a hard 400 (no redirect, exact-match only)', async () => { const res = mockRes() await mobileSso.start( { query: { provider: 'google', code_challenge: 'c', state: 's', redirect_uri: 'evil://auth/callback' }, ip: '1.2.3.4' }, res, ) assert.equal(res.statusCode, 400) assert.equal(res.redirectedTo, null) }) test('start: a prefix of the allowlisted URI is rejected (not a prefix match)', async () => { const res = mockRes() await mobileSso.start( { query: { provider: 'google', code_challenge: 'c', state: 's', redirect_uri: 'runicgateway://auth/callback.evil.com' }, ip: '1.2.3.4' }, res, ) assert.equal(res.statusCode, 400) }) test('start: seeds a bridge session and reuses the SSO redirect tagged mode:mobile', async () => { let startArgs = null let txData = null bridge.startSession = async (a) => { startArgs = a; return { sessionId: 'sess-99' } } const saved = ssoCtrl.redirectToIdp ssoCtrl.redirectToIdp = async (req, res, provider, data) => { txData = { provider, ...data }; res.redirect('https://idp/authorize'); return true } try { const res = mockRes() await mobileSso.start( { query: { provider: 'google', code_challenge: 'chal', state: 'st', redirect_uri: CALLBACK }, ip: '1.2.3.4' }, res, ) assert.equal(res.redirectedTo, 'https://idp/authorize') assert.equal(startArgs.redirectUri, CALLBACK) assert.equal(startArgs.codeChallenge, 'chal') assert.equal(txData.provider, 'google') assert.equal(txData.mode, 'mobile') assert.equal(txData.mobileSessionId, 'sess-99') } finally { ssoCtrl.redirectToIdp = saved } }) test('start: a disabled/unavailable provider surfaces the error to the app callback', async () => { const saved = ssoCtrl.redirectToIdp ssoCtrl.redirectToIdp = async () => false try { const res = mockRes() await mobileSso.start( { query: { provider: 'google', code_challenge: 'chal', state: 'st', redirect_uri: CALLBACK }, ip: '1.2.3.4' }, res, ) assert.equal(res.redirectedTo, `${CALLBACK}?error=provider_unavailable&state=st`) } finally { ssoCtrl.redirectToIdp = saved } }) // ── /exchange ──────────────────────────────────────────────────────────────── function exchangeReq(body) { return { body, ip: '127.0.0.1', headers: { 'user-agent': 'Android' } } } test('exchange: valid code + PKCE verifier → mobile bearer tokens (same shape as login)', async () => { const res = mockRes() await mobileSso.exchange(exchangeReq({ code: 'RAWCODE', code_verifier: 'verifier-xyz' }), res) assert.equal(res.statusCode, 200) assert.ok(res.body.accessToken, 'access token issued') assert.ok(res.body.refreshToken, 'refresh token issued') assert.equal(res.body.user.id, 7) // The issued access token validates as a mobile session. const s = require('../src/auth/session.service').validateBearerToken(res.body.accessToken) assert.equal(s.authMethod, 'mobile') assert.equal(logged.at(-1).action, 'auth.mobile.login') assert.equal(logged.at(-1).detail.sso, 'google') }) test('exchange: unknown/expired/used code → 401', async () => { bridge.findRedeemableCode = async () => null const res = mockRes() await mobileSso.exchange(exchangeReq({ code: 'nope', code_verifier: 'verifier-xyz' }), res) assert.equal(res.statusCode, 401) }) test('exchange: wrong PKCE verifier → 401 and the code is NOT consumed', async () => { let consumed = false bridge.consumeCode = async () => { consumed = true; return true } const res = mockRes() await mobileSso.exchange(exchangeReq({ code: 'RAWCODE', code_verifier: 'WRONG' }), res) assert.equal(res.statusCode, 401) assert.equal(consumed, false, 'a failed PKCE check must not burn a legitimate code') }) test('exchange: a reused (already-consumed) code → 401 (single use)', async () => { bridge.consumeCode = async () => false // lost the single-use race / replay const res = mockRes() await mobileSso.exchange(exchangeReq({ code: 'RAWCODE', code_verifier: 'verifier-xyz' }), res) assert.equal(res.statusCode, 401) }) test('exchange: vanished user → 401', async () => { users.getById = async () => null const res = mockRes() await mobileSso.exchange(exchangeReq({ code: 'RAWCODE', code_verifier: 'verifier-xyz' }), res) assert.equal(res.statusCode, 401) }) // ── callback (mode:'mobile') via sso.controller ────────────────────────────── function callbackReq(tx) { return { params: { provider: 'google' }, cookies: { [ssoState.TX_COOKIE]: tx.txToken }, query: { state: tx.nonce, code: 'idp-code' }, ip: '127.0.0.1', protocol: 'http', get: () => 'localhost', headers: {}, } } test('callback (mobile): linked account → deep link with a one-time code + echoed state, NO cookie', async () => { const tx = ssoState.createTx({ provider: 'google', mode: 'mobile', mobileSessionId: 'sess-1' }) const res = mockRes() await ssoCtrl.callback(callbackReq(tx), res) assert.equal(res.redirectedTo, `${CALLBACK}?code=RAWCODE&state=st-abc`) assert.equal(res.cookies[token.COOKIE_NAME], undefined, 'no web session cookie on a mobile flow') assert.equal(logged.at(-1).action, 'auth.sso.login') assert.equal(logged.at(-1).detail.mobile, true) }) test('callback (mobile): unlinked account with registration closed → error deep link (link-only)', async () => { userIdentities.findByProviderSubject = async () => null const tx = ssoState.createTx({ provider: 'google', mode: 'mobile', mobileSessionId: 'sess-1' }) const res = mockRes() await ssoCtrl.callback(callbackReq(tx), res) assert.equal(res.redirectedTo, `${CALLBACK}?error=not_linked&state=st-abc`) assert.equal(logged.length, 0) }) test('callback (mobile): a 2FA account is routed through the TOTP form, code NOT yet issued', async () => { users.getById = async (id) => ({ id, username: 'alice', role: 'player', totp_enabled: 1 }) let issued = false bridge.issueAuthCode = async () => { issued = true; return { code: 'RAWCODE' } } const tx = ssoState.createTx({ provider: 'google', mode: 'mobile', mobileSessionId: 'sess-1' }) const res = mockRes() await ssoCtrl.callback(callbackReq(tx), res) assert.equal(res.redirectedTo, '/account/login?sso_totp=1') assert.ok(res.cookies[ssoState.TOTP_COOKIE], 'pending-TOTP cookie staged') assert.equal(issued, false, 'no auth code before the second factor passes') // The staged challenge carries the bridge session so completion can deep-link back. const pending = ssoState.verifyTotpPending(res.cookies[ssoState.TOTP_COOKIE]) assert.equal(pending.mobileSessionId, 'sess-1') }) test('callback (mobile): an invalid/expired bridge session fails without leaking a redirect', async () => { bridge.getSession = async () => null const tx = ssoState.createTx({ provider: 'google', mode: 'mobile', mobileSessionId: 'gone' }) const res = mockRes() await ssoCtrl.callback(callbackReq(tx), res) assert.equal(res.statusCode, 400) assert.equal(res.redirectedTo, null) }) // ── finishSsoTotp (mode:'mobile') ──────────────────────────────────────────── function totpReq(pending, code) { return { cookies: pending ? { [ssoState.TOTP_COOKIE]: pending } : {}, body: { code }, ip: '127.0.0.1', protocol: 'http', get: () => 'localhost', headers: {}, } } test('finishSsoTotp (mobile): correct code → JSON { redirect } deep link, no cookie', async () => { users.getRawById = async (id) => ({ id, username: 'alice', role: 'player', totp_enabled: 1, totp_secret: 'S' }) totp.verifyCode = () => true const pending = ssoState.createTotpPending({ userId: 7, provider: 'google', authMethod: 'google', returnTo: '/account', mobileSessionId: 'sess-1' }) const res = mockRes() await ssoCtrl.finishSsoTotp(totpReq(pending, '123456'), res) assert.equal(res.body.redirect, `${CALLBACK}?code=RAWCODE&state=st-abc`) assert.equal(res.cookies[token.COOKIE_NAME], undefined, 'mobile 2FA completion sets no web session cookie') assert.ok(res.cleared.includes(ssoState.TOTP_COOKIE)) assert.equal(logged.at(-1).detail.totp, true) }) test('finishSsoTotp (mobile): expired bridge session → 401', async () => { users.getRawById = async (id) => ({ id, username: 'alice', role: 'player', totp_enabled: 1, totp_secret: 'S' }) totp.verifyCode = () => true bridge.getSession = async () => null const pending = ssoState.createTotpPending({ userId: 7, provider: 'google', authMethod: 'google', mobileSessionId: 'gone' }) const res = mockRes() await ssoCtrl.finishSsoTotp(totpReq(pending, '123456'), res) assert.equal(res.statusCode, 401) })