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:
83
server/test/mobileAuthBridge.model.test.js
Normal file
83
server/test/mobileAuthBridge.model.test.js
Normal 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)
|
||||
})
|
||||
264
server/test/mobileSsoBridge.test.js
Normal file
264
server/test/mobileSsoBridge.test.js
Normal 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)
|
||||
})
|
||||
Reference in New Issue
Block a user