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:
55
server/src/router/v1/auth/mobileSso.routes.js
Normal file
55
server/src/router/v1/auth/mobileSso.routes.js
Normal file
@@ -0,0 +1,55 @@
|
||||
const express = require('express')
|
||||
const { body, query } = require('express-validator')
|
||||
|
||||
const { start, exchange } = require('./mobileSso.controller')
|
||||
const { mobileSsoStartLimiter, mobileSsoExchangeLimiter } = require('../../../middleware/rateLimit')
|
||||
const validate = require('../../../middleware/validate')
|
||||
|
||||
// Mobile SSO authorization bridge (M9). Mounted at /auth/mobile/sso. Native
|
||||
// "Sign in with Google/Discord" that reuses the website's SSO flow and terminates
|
||||
// in the existing mobile bearer tokens — no OAuth secret ever ships in the app.
|
||||
// Provider discovery reuses GET /auth/providers; refresh/logout reuse the existing
|
||||
// /auth/mobile/{refresh,logout}. See docs BACKEND_DESIGN §4 + docs/android/PLAN.md §9.
|
||||
const mobileSsoRouter = express.Router()
|
||||
|
||||
// GET /auth/mobile/sso/start — opened by the app in a Custom Tab; 302s to the IdP.
|
||||
mobileSsoRouter.get(
|
||||
'/start',
|
||||
// #swagger.tags = ['Auth · Mobile']
|
||||
// #swagger.summary = 'Begin native SSO login (redirect to the IdP)'
|
||||
// #swagger.description = 'Opened by the Android app in a Custom Tab. Validates the provider is enabled and the redirect_uri is an exact match of a registered app callback, seeds a short-lived bridge session carrying the app PKCE challenge + state, and 302-redirects into the existing website SSO flow. On success the callback redirects to `redirect_uri?code=…&state=…` (a one-time code, never a token). Errors are surfaced to the app as `redirect_uri?error=…&state=…`.'
|
||||
// #swagger.parameters['provider'] = { in: 'query', required: true, schema: { type: 'string' }, description: 'Provider id from GET /auth/providers (e.g. google, discord).' }
|
||||
// #swagger.parameters['code_challenge'] = { in: 'query', required: true, schema: { type: 'string' }, description: 'App-generated PKCE S256 challenge (base64url).' }
|
||||
// #swagger.parameters['state'] = { in: 'query', required: true, schema: { type: 'string' }, description: 'App-generated opaque CSRF value, echoed on the callback for the app to verify.' }
|
||||
// #swagger.parameters['redirect_uri'] = { in: 'query', required: true, schema: { type: 'string' }, description: 'The app callback; must EXACTLY match a registered value (default runicgateway://auth/callback).' }
|
||||
/* #swagger.responses[302] = { description: 'Redirect to the identity provider (or back to the app callback on error)' } */
|
||||
/* #swagger.responses[400] = { description: 'Unrecognized redirect URI or validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[429] = { description: 'Too many attempts (rate limited)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
mobileSsoStartLimiter,
|
||||
query('provider').isString().trim().isLength({ min: 1, max: 40 }),
|
||||
query('code_challenge').isString().trim().isLength({ min: 20, max: 255 }),
|
||||
query('state').isString().trim().isLength({ min: 8, max: 255 }),
|
||||
query('redirect_uri').isString().trim().isLength({ min: 1, max: 255 }),
|
||||
validate,
|
||||
start,
|
||||
)
|
||||
|
||||
// POST /auth/mobile/sso/exchange — code + PKCE verifier → mobile bearer tokens.
|
||||
mobileSsoRouter.post(
|
||||
'/exchange',
|
||||
// #swagger.tags = ['Auth · Mobile']
|
||||
// #swagger.summary = 'Exchange an SSO authorization code for mobile tokens'
|
||||
// #swagger.description = 'Redeems the single-use authorization code returned to the app callback, together with the PKCE code_verifier, for the SAME access + refresh pair as /auth/mobile/login. The code is single-use and PKCE-bound: a wrong verifier, an expired/used code, or a reused code all fail 401.'
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/MobileSsoExchangeRequest" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Access + refresh tokens', content: { "application/json": { schema: { $ref: "#/components/schemas/MobileTokenResponse" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Invalid/expired/used code or failed PKCE verification', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[429] = { description: 'Too many attempts (rate limited)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
mobileSsoExchangeLimiter,
|
||||
body('code').isString().trim().isLength({ min: 20, max: 255 }),
|
||||
body('code_verifier').isString().trim().isLength({ min: 20, max: 255 }),
|
||||
validate,
|
||||
exchange,
|
||||
)
|
||||
|
||||
module.exports = mobileSsoRouter
|
||||
Reference in New Issue
Block a user