feat(auth): native SSO authorization bridge for the Android app

Add a Mobile SSO Authorization Bridge so the native app can "Sign in with
Google/Discord" without shipping any OAuth secret. It EXTENDS the existing
/auth/sso/* redirect flow (same PKCE-vs-IdP, link-only + opt-in provisioning,
TOTP gate) and terminates in the existing mobile bearer tokens — not a parallel
auth path.

- Schema: mobile_auth_sessions + mobile_auth_codes (short-lived, self-pruning;
  authorization code stored hash-only, PKCE challenge is a hash by construction).
- GET /auth/mobile/sso/start: validate provider enabled + redirect_uri by EXACT
  allowlist match (never prefix), seed a bridge session, reuse the SSO redirect
  tagged mode:'mobile' (new redirectToIdp helper extracted from beginFlow).
- SSO callback + finishSsoTotp gain a mode:'mobile' branch: mint a single-use,
  hashed, PKCE-bound code and redirect to the fixed app callback (code + echoed
  state, never a token) instead of setting a cookie. 2FA keeps full parity via
  the existing web TOTP form (now carrying the bridge session).
- POST /auth/mobile/sso/exchange: verify Layer-B PKCE (before burning the code),
  single-use consume, then issue the SAME pair as /auth/mobile/login.
- Discovery reuses GET /auth/providers; refresh/logout reuse /auth/mobile/*.
- Rate limits: /start per-IP+provider, /exchange per-IP. Boot-time +
  opportunistic prune of both tables (no cron, mirrors revoked_sessions).
- Redirect allowlist is MOBILE_AUTH_REDIRECT_URIS (default the one fixed
  runicgateway://auth/callback); App Link URIs can be appended per shard later.
- Swagger regenerated; 39 tests (model single-use/gating + full controller
  matrix: bad/expired/reused code, PKCE mismatch, disabled provider, redirect
  allowlist, TOTP-through-bridge). Full suite green (271).

Refs docs/website/BACKEND_DESIGN.md, docs/android/PLAN.md §9 (M9).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-20 16:55:06 -05:00
parent 31b72859ce
commit 61f4591a6b
14 changed files with 1200 additions and 20 deletions

View File

@@ -0,0 +1,83 @@
// Pure-logic tests for the mobile SSO bridge MODEL. The .db layer is stubbed
// (plain object exports → mutable in-process), so these are DB-free and only
// exercise the model's gating, hashing, and code-generation logic.
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret'
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 crypto = require('crypto')
const bridge = require('../src/model/mobileAuthBridge/mobileAuthBridge.model')
const bridgeDb = require('../src/model/mobileAuthBridge/mobileAuthBridge.db')
const db = require('../src/utils/db')
after(() => db.close())
const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex')
let calls
beforeEach(() => {
calls = { insertSession: [], insertCode: [], completeSession: [], markCodeUsed: [], findValidCode: [] }
bridgeDb.insertSession = async (a) => { calls.insertSession.push(a); return 1 }
bridgeDb.insertCode = async (a) => { calls.insertCode.push(a); return 1 }
bridgeDb.completeSession = async (sid, uid) => { calls.completeSession.push([sid, uid]); return 1 }
bridgeDb.consumeSession = async () => 1
bridgeDb.getSession = async () => null
bridgeDb.findValidCode = async (h) => { calls.findValidCode.push(h); return { code_hash: h, user_id: 9, session_id: 's' } }
bridgeDb.markCodeUsed = async () => 1
bridgeDb.pruneExpired = async () => 0
})
test('startSession generates a uuid session_id and persists the row', async () => {
const { sessionId } = await bridge.startSession({
provider: 'google', codeChallenge: 'chal', redirectUri: 'runicgateway://auth/callback', state: 'st',
})
assert.match(sessionId, /^[0-9a-f-]{36}$/)
assert.equal(calls.insertSession.length, 1)
assert.equal(calls.insertSession[0].sessionId, sessionId)
assert.equal(calls.insertSession[0].provider, 'google')
assert.ok(calls.insertSession[0].expiresAt instanceof Date)
assert.ok(calls.insertSession[0].expiresAt.getTime() > Date.now())
})
test('issueAuthCode: completes the session, stores only the code HASH, returns the raw code once', async () => {
const out = await bridge.issueAuthCode({ sessionId: 's-1', userId: 42 })
assert.ok(out && typeof out.code === 'string')
// >=128-bit, url-safe, opaque.
assert.ok(out.code.length >= 22, 'at least 128 bits of base64url entropy')
assert.match(out.code, /^[A-Za-z0-9_-]+$/)
// Session was marked completed for THIS user.
assert.deepEqual(calls.completeSession[0], ['s-1', 42])
// Only the sha256 hash of the code is persisted; the raw code never is.
assert.equal(calls.insertCode[0].codeHash, sha256(out.code))
assert.equal(calls.insertCode[0].userId, 42)
assert.equal(calls.insertCode[0].sessionId, 's-1')
})
test('issueAuthCode returns null (mints no code) when the session is not pending/eligible', async () => {
bridgeDb.completeSession = async () => 0 // not pending / expired / already used
const out = await bridge.issueAuthCode({ sessionId: 's-1', userId: 42 })
assert.equal(out, null)
assert.equal(calls.insertCode.length, 0, 'no code row when the session could not be completed')
})
test('findRedeemableCode looks up by the code HASH, not the raw code', async () => {
const row = await bridge.findRedeemableCode('raw-code-value')
assert.equal(calls.findValidCode[0], sha256('raw-code-value'))
assert.equal(row.user_id, 9)
})
test('consumeCode is single-use: true only when a row was actually flipped', async () => {
bridgeDb.markCodeUsed = async () => 1
assert.equal(await bridge.consumeCode('c'), true)
bridgeDb.markCodeUsed = async () => 0 // already used / raced
assert.equal(await bridge.consumeCode('c'), false)
})
test('two issued codes are distinct (fresh entropy each time)', async () => {
const a = await bridge.issueAuthCode({ sessionId: 's', userId: 1 })
const b = await bridge.issueAuthCode({ sessionId: 's', userId: 1 })
assert.notEqual(a.code, b.code)
})

View File

@@ -0,0 +1,264 @@
// 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)
})