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

@@ -70,10 +70,15 @@ function verifyTx(txToken, stateNonce) {
// 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.
function createTotpPending({ userId, provider, authMethod, returnTo }) {
//
// `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 },
{ stage: 'totp', kind: 'sso_totp', provider, authMethod, returnTo, mobileSessionId },
{ expiresIn: TOTP_TTL },
)
}