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>
84 lines
3.9 KiB
JavaScript
84 lines
3.9 KiB
JavaScript
// 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)
|
|
})
|