Files
website/server/src/middleware/rateLimit.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

133 lines
4.6 KiB
JavaScript

const rateLimit = require('express-rate-limit')
const log = require('../utils/logger')('ratelimit')
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)
},
})
}
// Brute-force protection on login.
const loginLimiter = makeLimiter({
windowMs: 15 * 60 * 1000,
max: 10,
label: 'login',
message: 'Too many login attempts. Please try again later.',
})
// Public self-registration. Mirrors the login cap: a handful of legitimate
// attempts per window, a flood is abuse. The global botScore guard + honeypot
// cover the rest.
const registerLimiter = makeLimiter({
windowMs: 15 * 60 * 1000,
max: 10,
label: 'register',
message: 'Too many registration attempts. Please try again later.',
})
// Authenticated self-service credential changes (username / password). Tighter
// than login — a signed-in player rarely changes these, and the wrong-current-
// password path also feeds the shared login backoff (see the controller).
const accountChangeLimiter = makeLimiter({
windowMs: 15 * 60 * 1000,
max: 10,
label: 'account-change',
message: 'Too many changes. Please try again later.',
})
// Throttle the public contact form.
const contactLimiter = makeLimiter({
windowMs: 60 * 60 * 1000,
max: 5,
label: 'contact',
message: 'Too many messages sent. Please try again later.',
})
// Cap mobile refresh-token exchanges per IP. Legitimate apps refresh at most a
// handful of times per window; a flood is either a bug or an attempt to brute
// the refresh endpoint.
const mobileRefreshLimiter = makeLimiter({
windowMs: 15 * 60 * 1000,
max: 30,
label: 'mobile-refresh',
message: 'Too many refresh attempts. Please try again later.',
})
// Throttle SSO redirect starts per IP — cheap to trigger, and a flood is either a
// bug or an attempt to spin the OAuth flow. Generous enough for real users.
const ssoStartLimiter = makeLimiter({
windowMs: 15 * 60 * 1000,
max: 30,
label: 'sso-start',
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.
const passwordResetRequestLimiter = makeLimiter({
windowMs: 60 * 60 * 1000,
max: 5,
label: 'password-reset-request',
message: 'Too many reset requests. Please try again later.',
})
// Reset confirmations (token + new password) per IP. A wrong/expired token is a
// guessing surface; the token itself is 256-bit random, but cap anyway.
const passwordResetConfirmLimiter = makeLimiter({
windowMs: 15 * 60 * 1000,
max: 15,
label: 'password-reset-confirm',
message: 'Too many attempts. Please try again later.',
})
module.exports = {
loginLimiter,
registerLimiter,
accountChangeLimiter,
contactLimiter,
mobileRefreshLimiter,
ssoStartLimiter,
mobileSsoStartLimiter,
mobileSsoExchangeLimiter,
passwordResetRequestLimiter,
passwordResetConfirmLimiter,
}