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:
@@ -2,13 +2,18 @@ const rateLimit = require('express-rate-limit')
|
||||
|
||||
const log = require('../utils/logger')('ratelimit')
|
||||
|
||||
function makeLimiter({ windowMs, max, label, message }) {
|
||||
function makeLimiter({ windowMs, max, label, message, keyGenerator, validate }) {
|
||||
return rateLimit({
|
||||
windowMs,
|
||||
max,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { message },
|
||||
// Default key is the client IP; callers can widen it (e.g. IP + provider).
|
||||
...(keyGenerator ? { keyGenerator } : {}),
|
||||
// Custom keyGenerators that fold in req.ip trip v7's IPv6 fallback validator;
|
||||
// callers pass `validate` to scope that off just for their limiter.
|
||||
...(validate !== undefined ? { validate } : {}),
|
||||
handler: (req, res, next, options) => {
|
||||
log.warn(`${label} rate limit exceeded`, { ip: req.ip, path: req.originalUrl })
|
||||
res.status(options.statusCode).json(options.message)
|
||||
@@ -71,6 +76,29 @@ const ssoStartLimiter = makeLimiter({
|
||||
message: 'Too many sign-in attempts. Please try again later.',
|
||||
})
|
||||
|
||||
// Mobile SSO bridge — throttle /start per IP AND per provider: each call spawns a
|
||||
// mobile_auth_sessions row, so without a per-provider dimension /start is a cheap
|
||||
// way to spam rows for one provider from many-but-few IPs. Generous for real users
|
||||
// (a login is a handful of taps). `validate:{ip:false}` scopes off v7's IPv6
|
||||
// fallback check, which fires only because our key folds in req.ip.
|
||||
const mobileSsoStartLimiter = makeLimiter({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 20,
|
||||
label: 'mobile-sso-start',
|
||||
message: 'Too many sign-in attempts. Please try again later.',
|
||||
keyGenerator: (req) => `${req.ip}:${req.query && req.query.provider ? req.query.provider : ''}`,
|
||||
validate: { ip: false },
|
||||
})
|
||||
|
||||
// Mobile SSO bridge — throttle /exchange per IP. The code is single-use, PKCE-bound
|
||||
// and short-lived, but cap redemption attempts anyway to blunt guessing.
|
||||
const mobileSsoExchangeLimiter = makeLimiter({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 30,
|
||||
label: 'mobile-sso-exchange',
|
||||
message: 'Too many attempts. Please try again later.',
|
||||
})
|
||||
|
||||
// Password-reset requests per IP. Each one can send email, so cap tighter than
|
||||
// login to blunt email-bombing and enumeration timing probes. The endpoint always
|
||||
// returns a generic success regardless of match, so honest users never see this.
|
||||
@@ -97,6 +125,8 @@ module.exports = {
|
||||
contactLimiter,
|
||||
mobileRefreshLimiter,
|
||||
ssoStartLimiter,
|
||||
mobileSsoStartLimiter,
|
||||
mobileSsoExchangeLimiter,
|
||||
passwordResetRequestLimiter,
|
||||
passwordResetConfirmLimiter,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user