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:
101
server/src/model/mobileAuthBridge/mobileAuthBridge.db.js
Normal file
101
server/src/model/mobileAuthBridge/mobileAuthBridge.db.js
Normal file
@@ -0,0 +1,101 @@
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
// SQL for the mobile SSO authorization bridge (mobile_auth_sessions +
|
||||
// mobile_auth_codes). The authorization code is stored as a sha256 hash only
|
||||
// (hashing is done in the .model layer, mirroring mobileSessions); the PKCE
|
||||
// code_challenge is a hash by construction and is stored as-supplied. Both
|
||||
// tables self-prune on expiry (pruneExpired). See docs BACKEND_DESIGN §3/§4.
|
||||
|
||||
// ── mobile_auth_sessions ───────────────────────────────────────────────────
|
||||
|
||||
// Insert a pending bridge session. expiresAt is a JS Date (or ms epoch).
|
||||
async function insertSession({ sessionId, provider, codeChallenge, redirectUri, state, expiresAt }) {
|
||||
const res = await query(
|
||||
`INSERT INTO mobile_auth_sessions (session_id, provider, code_challenge, redirect_uri, state, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[sessionId, provider, codeChallenge, redirectUri, state, new Date(expiresAt)],
|
||||
)
|
||||
return res.insertId
|
||||
}
|
||||
|
||||
// Look up a bridge session by its opaque session_id. Returns the row or null.
|
||||
async function getSession(sessionId) {
|
||||
const rows = await query('SELECT * FROM mobile_auth_sessions WHERE session_id = ? LIMIT 1', [sessionId])
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
// Mark a still-pending, unexpired session `completed` and attach the resolved
|
||||
// user. Guards on status + expiry so a replayed callback can't re-complete a
|
||||
// consumed/expired session. Returns rows changed (0 = not eligible).
|
||||
async function completeSession(sessionId, userId) {
|
||||
const res = await query(
|
||||
`UPDATE mobile_auth_sessions
|
||||
SET status = 'completed', user_id = ?
|
||||
WHERE session_id = ? AND status = 'pending' AND expires_at > NOW()`,
|
||||
[userId, sessionId],
|
||||
)
|
||||
return Number(res.affectedRows || 0)
|
||||
}
|
||||
|
||||
// Mark a session `consumed` after a successful token exchange (stamps used_at).
|
||||
async function consumeSession(sessionId) {
|
||||
const res = await query(
|
||||
"UPDATE mobile_auth_sessions SET status = 'consumed', used_at = NOW() WHERE session_id = ?",
|
||||
[sessionId],
|
||||
)
|
||||
return Number(res.affectedRows || 0)
|
||||
}
|
||||
|
||||
// ── mobile_auth_codes ──────────────────────────────────────────────────────
|
||||
|
||||
// Insert a freshly minted authorization code (by hash).
|
||||
async function insertCode({ codeHash, userId, sessionId, expiresAt }) {
|
||||
const res = await query(
|
||||
`INSERT INTO mobile_auth_codes (code_hash, user_id, session_id, expires_at)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
[codeHash, userId, sessionId, new Date(expiresAt)],
|
||||
)
|
||||
return res.insertId
|
||||
}
|
||||
|
||||
// Return an authorization-code row only if it is still redeemable: not used and
|
||||
// not expired. Returns the row (incl. user_id, session_id) or null.
|
||||
async function findValidCode(codeHash) {
|
||||
const rows = await query(
|
||||
`SELECT * FROM mobile_auth_codes
|
||||
WHERE code_hash = ? AND used_at IS NULL AND expires_at > NOW()
|
||||
LIMIT 1`,
|
||||
[codeHash],
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
// Atomically mark a code used (single-use gate). Only affects a not-yet-used row,
|
||||
// so a concurrent double-redeem sees affectedRows = 0 on the loser. Returns rows
|
||||
// changed.
|
||||
async function markCodeUsed(codeHash) {
|
||||
const res = await query(
|
||||
'UPDATE mobile_auth_codes SET used_at = NOW() WHERE code_hash = ? AND used_at IS NULL',
|
||||
[codeHash],
|
||||
)
|
||||
return Number(res.affectedRows || 0)
|
||||
}
|
||||
|
||||
// Housekeeping: drop long-dead rows from both bridge tables (expired, or codes
|
||||
// already used). Keeps the tables from growing without bound. Returns rows removed.
|
||||
async function pruneExpired() {
|
||||
const a = await query('DELETE FROM mobile_auth_codes WHERE expires_at < NOW() OR used_at IS NOT NULL')
|
||||
const b = await query("DELETE FROM mobile_auth_sessions WHERE expires_at < NOW() OR status = 'consumed'")
|
||||
return Number(a.affectedRows || 0) + Number(b.affectedRows || 0)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
insertSession,
|
||||
getSession,
|
||||
completeSession,
|
||||
consumeSession,
|
||||
insertCode,
|
||||
findValidCode,
|
||||
markCodeUsed,
|
||||
pruneExpired,
|
||||
}
|
||||
102
server/src/model/mobileAuthBridge/mobileAuthBridge.model.js
Normal file
102
server/src/model/mobileAuthBridge/mobileAuthBridge.model.js
Normal file
@@ -0,0 +1,102 @@
|
||||
// ── Mobile SSO authorization bridge — logic layer ──────────────────────────
|
||||
//
|
||||
// Bridges the browser SSO redirect flow to the native app. A `/start` seeds a
|
||||
// `mobile_auth_sessions` row carrying the app-supplied PKCE challenge, the app's
|
||||
// CSRF `state`, and the exact callback URI. The SSO callback (once the account is
|
||||
// resolved) mints a single-use authorization CODE tied to that session; the app
|
||||
// redeems the code + its PKCE verifier at `/exchange` for bearer tokens.
|
||||
//
|
||||
// Secrets: the raw authorization code is generated here and returned ONCE to the
|
||||
// caller (to place in the redirect URL); only its sha256 hash is persisted — the
|
||||
// raw code never touches the DB. The PKCE code_challenge is already a hash.
|
||||
|
||||
const crypto = require('crypto')
|
||||
|
||||
const db = require('./mobileAuthBridge.db')
|
||||
|
||||
// TTLs. A start→callback→exchange round-trip is quick, but the callback may route
|
||||
// through the TOTP form, so give the session a little room; the code itself is
|
||||
// very short-lived.
|
||||
const SESSION_TTL_MS = 10 * 60 * 1000 // 10 min — one redirect round-trip incl. TOTP
|
||||
const CODE_TTL_MS = 5 * 60 * 1000 // 5 min — the app redeems immediately
|
||||
|
||||
// sha256 hex of a raw token — the persisted representation of an authorization code.
|
||||
function hashCode(raw) {
|
||||
return crypto.createHash('sha256').update(String(raw)).digest('hex')
|
||||
}
|
||||
|
||||
// Create a pending bridge session. Returns { sessionId } (opaque uuid) which the
|
||||
// caller embeds in the signed sso_tx so the callback can find this row. Best-effort
|
||||
// prune keeps the table trimmed without a cron (same approach as revoked_sessions).
|
||||
async function startSession({ provider, codeChallenge, redirectUri, state, now = Date.now() }) {
|
||||
const sessionId = crypto.randomUUID()
|
||||
await db.insertSession({
|
||||
sessionId,
|
||||
provider,
|
||||
codeChallenge,
|
||||
redirectUri,
|
||||
state,
|
||||
expiresAt: new Date(now + SESSION_TTL_MS),
|
||||
})
|
||||
db.pruneExpired().catch(() => {}) // opportunistic; never blocks the flow
|
||||
return { sessionId }
|
||||
}
|
||||
|
||||
// Fetch a bridge session row by session_id (or null).
|
||||
async function getSession(sessionId) {
|
||||
if (!sessionId) return null
|
||||
return db.getSession(sessionId)
|
||||
}
|
||||
|
||||
// Mint the one-time authorization code for a resolved account. Marks the owning
|
||||
// session `completed`; returns { code } (the RAW code, to place in the redirect
|
||||
// URL) or null if the session isn't pending/eligible. 256 bits of entropy — well
|
||||
// above the ≥128-bit floor — url-safe and opaque.
|
||||
async function issueAuthCode({ sessionId, userId, now = Date.now() }) {
|
||||
const completed = await db.completeSession(sessionId, userId)
|
||||
if (!completed) return null // not pending / expired / already used
|
||||
const code = crypto.randomBytes(32).toString('base64url')
|
||||
await db.insertCode({
|
||||
codeHash: hashCode(code),
|
||||
userId,
|
||||
sessionId,
|
||||
expiresAt: new Date(now + CODE_TTL_MS),
|
||||
})
|
||||
db.pruneExpired().catch(() => {})
|
||||
return { code }
|
||||
}
|
||||
|
||||
// Look up a redeemable code row (unused + unexpired) for a raw code, or null.
|
||||
async function findRedeemableCode(rawCode) {
|
||||
if (!rawCode) return null
|
||||
return db.findValidCode(hashCode(rawCode))
|
||||
}
|
||||
|
||||
// Atomically burn a code (single-use). Returns true iff THIS call was the one that
|
||||
// marked it used — a concurrent double-redeem gets false on the loser.
|
||||
async function consumeCode(rawCode) {
|
||||
const changed = await db.markCodeUsed(hashCode(rawCode))
|
||||
return changed > 0
|
||||
}
|
||||
|
||||
// Mark a session fully consumed after a successful exchange.
|
||||
async function finishSession(sessionId) {
|
||||
return db.consumeSession(sessionId)
|
||||
}
|
||||
|
||||
// Drop expired/used rows from both tables.
|
||||
async function pruneExpired() {
|
||||
return db.pruneExpired()
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SESSION_TTL_MS,
|
||||
CODE_TTL_MS,
|
||||
startSession,
|
||||
getSession,
|
||||
issueAuthCode,
|
||||
findRedeemableCode,
|
||||
consumeCode,
|
||||
finishSession,
|
||||
pruneExpired,
|
||||
}
|
||||
Reference in New Issue
Block a user