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