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>
107 lines
4.6 KiB
JavaScript
107 lines
4.6 KiB
JavaScript
// ── SSO transaction state (CSRF + PKCE) ────────────────────────────────────
|
|
//
|
|
// An OAuth redirect flow spans two requests (start → callback) with a hop to the
|
|
// IdP in between, so we must carry state across it safely:
|
|
//
|
|
// - CSRF: an attacker must not be able to forge a callback. We bind the flow to
|
|
// the user's browser with a short-lived, signed, httpOnly cookie (sso_tx) and
|
|
// put only an opaque `nonce` in the URL `state` param. The callback requires
|
|
// state === cookie.nonce, so a callback not initiated by this browser fails.
|
|
// - PKCE: the code_verifier is generated at start, kept ONLY in the httpOnly
|
|
// cookie (never in the URL/logs), and sent to the token endpoint at callback.
|
|
//
|
|
// The cookie is a signed JWT (reusing the app's JWT signing) with a tight TTL, so
|
|
// it cannot be tampered with and expires quickly if a flow is abandoned.
|
|
|
|
const crypto = require('crypto')
|
|
const token = require('./token')
|
|
|
|
const TX_COOKIE = 'sso_tx'
|
|
const TX_TTL = '10m' // a login round-trip is quick; abandon after 10 minutes
|
|
|
|
// Second leg of an SSO login for an account that has TOTP enabled. The callback
|
|
// authenticated the user with the IdP but must NOT bypass their second factor
|
|
// (see issue #31), so instead of minting a session it stages this signed,
|
|
// httpOnly cookie and routes the browser through the TOTP form — mirroring the
|
|
// local password→TOTP gate. TTL matches the local challenge window.
|
|
const TOTP_COOKIE = 'sso_totp'
|
|
const TOTP_TTL = '5m'
|
|
|
|
// base64url of random bytes — used for the nonce and the PKCE verifier.
|
|
function randomUrlSafe(bytes = 32) {
|
|
return crypto.randomBytes(bytes).toString('base64url')
|
|
}
|
|
|
|
// PKCE S256 challenge for a given verifier.
|
|
function codeChallengeFor(verifier) {
|
|
return crypto.createHash('sha256').update(verifier).digest('base64url')
|
|
}
|
|
|
|
// Create a transaction: returns { nonce, verifier, codeChallenge, txToken }.
|
|
// `data` = { provider, mode ('login'|'link'), linkUserId?, returnTo? }.
|
|
function createTx(data) {
|
|
const nonce = randomUrlSafe(16)
|
|
const verifier = randomUrlSafe(32)
|
|
const codeChallenge = codeChallengeFor(verifier)
|
|
const txToken = token.signToken(
|
|
{ id: 'sso' }, // subject is irrelevant; this is a flow token, not a session
|
|
{ nonce, verifier, ...data, kind: 'sso_tx' },
|
|
{ expiresIn: TX_TTL },
|
|
)
|
|
return { nonce, verifier, codeChallenge, txToken }
|
|
}
|
|
|
|
// Verify a tx cookie against the state param. Returns the tx payload
|
|
// ({ nonce, verifier, provider, mode, ... }) or null if missing/expired/mismatched.
|
|
function verifyTx(txToken, stateNonce) {
|
|
if (!txToken || !stateNonce) return null
|
|
const decoded = token.verifyToken(txToken)
|
|
if (!decoded || decoded.kind !== 'sso_tx') return null
|
|
// Constant-time compare so a mismatch can't be timed.
|
|
const a = Buffer.from(String(decoded.nonce))
|
|
const b = Buffer.from(String(stateNonce))
|
|
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null
|
|
return decoded
|
|
}
|
|
|
|
// Stage the pending second factor for an SSO login. Carries the context the
|
|
// callback already resolved (userId, provider, authMethod, returnTo) so that
|
|
// presenting a valid code alone finishes the login. It is deliberately NOT a
|
|
// session: `stage: 'totp'` makes session validation reject it (same marker the
|
|
// local TOTP challenge uses), and `kind: 'sso_totp'` both reinforces that and
|
|
// scopes it to the SSO completion endpoint.
|
|
//
|
|
// `mobileSessionId` is present only for a mobile SSO bridge flow (mode 'mobile'):
|
|
// it threads the bridge session through the TOTP form so that, on a correct code,
|
|
// the completion mints an authorization code + deep-links back to the app instead
|
|
// of setting a web session cookie. Absent for ordinary web SSO.
|
|
function createTotpPending({ userId, provider, authMethod, returnTo, mobileSessionId }) {
|
|
return token.signToken(
|
|
{ id: userId }, // subject only; identity is re-loaded fresh when the code is verified
|
|
{ stage: 'totp', kind: 'sso_totp', provider, authMethod, returnTo, mobileSessionId },
|
|
{ expiresIn: TOTP_TTL },
|
|
)
|
|
}
|
|
|
|
// Verify a pending-TOTP cookie. Returns the payload
|
|
// ({ id, provider, authMethod, returnTo, ... }) or null if missing/expired/wrong-kind.
|
|
function verifyTotpPending(pendingToken) {
|
|
if (!pendingToken) return null
|
|
const decoded = token.verifyToken(pendingToken)
|
|
if (!decoded || decoded.stage !== 'totp' || decoded.kind !== 'sso_totp') return null
|
|
return decoded
|
|
}
|
|
|
|
module.exports = {
|
|
TX_COOKIE,
|
|
TX_TTL,
|
|
TOTP_COOKIE,
|
|
TOTP_TTL,
|
|
createTx,
|
|
verifyTx,
|
|
createTotpPending,
|
|
verifyTotpPending,
|
|
codeChallengeFor,
|
|
randomUrlSafe,
|
|
}
|