Files
website/server/src/auth/ssoState.js
wtclaude 61f4591a6b 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>
2026-07-20 16:55:06 -05:00

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