diff --git a/.changeset/flow-rate-limits.md b/.changeset/flow-rate-limits.md new file mode 100644 index 0000000..9900b88 --- /dev/null +++ b/.changeset/flow-rate-limits.md @@ -0,0 +1,23 @@ +--- +'seamless-auth-api': minor +--- + +Read the per-flow rate limits from system config, and document what a native client needs. + +The OTP, magic link and OAuth limiters carried their values as constants: 10 OTP sends and 20 +magic links per IP per 15 minutes, 5 per address, 30 OAuth starts per IP and 10 per provider. +Those suit a web audience and refuse a mobile one, because carriers put thousands of +subscribers behind one IPv4 address. The six limiters now read `flow_rate_limits` from system +config (`FLOW_RATE_LIMITS` from the environment on first boot), an object whose defaults are +exactly those constants, so an instance that sets nothing behaves as it did. A changed limit +applies on the next request; a changed `windowSeconds` builds a fresh limiter for that window. +`perIdentity` values guard the address and rarely need changing; `perIp` values are what a +deployment serving a native app raises. + +`@seamless-auth/types` moves to 0.22.0, which adds the key, and `openapi.json` and the generated +types pick it up on the system config routes. + +Docs gain a "Native and mobile clients" section in `api-contract.md` (RP ID and origins for iOS +and Android, magic link and OAuth destinations, the tenant-wide session lifetime, refresh reuse +detection, authenticator policy), a "Flow rate limits" section in `configuration.md`, and the +client SDK packages in `ecosystem.md`. diff --git a/.env.example b/.env.example index ea429d2..3b90589 100644 --- a/.env.example +++ b/.env.example @@ -83,6 +83,9 @@ SESSION_IDLE_TTL=8h MAX_CONCURRENT_SESSIONS=unlimited RATE_LIMIT=100 DELAY_AFTER=50 +# Per-flow limits for OTP, magic link and OAuth starts, per 15-minute window. The +# defaults suit a web audience; raise the perIp values for a native app behind carrier NAT. +# FLOW_RATE_LIMITS={"windowSeconds":900,"otp":{"perIp":10,"perIdentity":5},"magicLink":{"perIp":20,"perIdentity":5},"oauth":{"perIp":30,"perProvider":10}} # Testing escape hatch. When true, all auth rate limiters (global, OTP, magic link, # registration, OAuth, JWKS) are skipped so an automated suite driving many flows # from one IP is not throttled. Ignored under NODE_ENV=production, so it can never diff --git a/docs/api-contract.md b/docs/api-contract.md index 389c364..d31c81a 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -85,6 +85,41 @@ a destination on a configured origin that is not listed is refused. That is deli that needs a mobile scheme should not have to widen `origins`, which gates passkey ceremonies, to get it. +### Native and mobile clients + +Nothing in this contract is browser-specific. A native app completes every flow with the same +routes, either directly against this API or, more usually, through a server adapter running in +bearer transport (`x-seamless-auth-transport: bearer`, `@seamless-auth/express` or +`@seamless-auth/fastify` 0.16 or later), which keeps the adapter's message delivery and client +IP forwarding in the path while the app holds the tokens. What a deployment has to configure: + +- **`rpid` and `origins`.** The RP ID is the domain the app's association files are hosted on + (`apple-app-site-association` with `webcredentials`, `assetlinks.json`), and the web app must + be same-site with it. iOS reports a passkey's origin as `https://`, so that exact + origin belongs in `origins` even when the web app lives on a subdomain. Android reports + `android:apk-key-hash:`, which is accepted + as an opaque URL and must be listed for native passkeys to verify. Keep the web origin first: + `origins[0]` is the fallback destination for magic links and OAuth. +- **Magic links.** A universal link on the web origin needs nothing extra. A custom scheme goes + in `magic_link_redirect_uris` (exact match, see above). The app can also skip the link entirely + and poll `GET /magic-link/check` with the ephemeral token until the session arrives, since + `/magic-link/verify/:token` completes the link from whichever device opened it. +- **OAuth.** A custom-scheme `redirectUri` is accepted when it is on the provider's + `redirectUris` allowlist. Some providers refuse custom schemes on a web client type; a + universal link on the web origin's `/oauth/callback` sidesteps that. +- **Rate limits.** The per-IP flow limits in `flow_rate_limits` are sized for a web audience. + Raise the `perIp` values for an app behind carrier NAT; see + [configuration.md](./configuration.md#flow-rate-limits). +- **Session lifetime** is tenant-wide. `refresh_token_ttl` is the absolute lifetime and + `session_idle_ttl` the idle bound; a phone that is closed for longer than the shorter of the + two signs out. There is no per-client profile, so a deployment that wants a long-lived mobile + session gives every client one. +- **Refresh** rotates the refresh token and treats a replay as theft, revoking the whole chain + with `401 { "error": "refresh_token_reused" }`. A client must refresh once at a time. +- **Authenticator policy.** The defaults (`attestation: "none"`, `syncedPasskeys: "allow"`) + admit iCloud Keychain and Google Password Manager passkeys. A tenant on + `syncedPasskeys: "block"` refuses every iOS passkey. + ### Error body Every `4xx` and `5xx` response uses one shape, with one additive extension for schema diff --git a/docs/configuration.md b/docs/configuration.md index f038226..9105a97 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -309,11 +309,35 @@ Validation is enforced by [`systemConfig.schema.ts`](../src/schemas/systemConfig | `refresh_token_ttl` | string (`\d+[smhd]`) | `REFRESH_TOKEN_TTL` | - | | `rate_limit` | integer > 0 | `RATE_LIMIT` | - | | `delay_after` | integer >= 0 | `DELAY_AFTER` | - | +| `flow_rate_limits` | object | `FLOW_RATE_LIMITS` | `{windowSeconds:900,otp:{perIp:10,perIdentity:5},magicLink:{perIp:20,perIdentity:5},oauth:{perIp:30,perProvider:10}}` | | `rpid` | string | `RPID` | - | | `origins` | url[] | `ORIGINS` | - | | `frontend_url` | url | `FRONTEND_URL` | - | | `magic_link_redirect_uris` | string[] | `MAGIC_LINK_REDIRECT_URIS` | `[]` | +### Flow rate limits + +`rate_limit` bounds every request per IP per minute. The message-carrying and provider flows +carry a second, tighter set of limits, `flow_rate_limits`, applied per 15-minute window (the +`windowSeconds` default) and split two ways per flow: + +- `perIdentity` bounds how often one address or phone can be messaged (`otp`, `magicLink`) and + how often one provider can be started from one address (`oauth.perProvider`). This is the + abuse a sender cares about, and it rarely needs changing. +- `perIp` bounds how many distinct flows one network location can drive. This is the + enumeration and spam guard, and it is the one a mobile audience runs into: carriers put + thousands of subscribers behind one IPv4 address, so `otp.perIp: 10` refuses a phone + audience at modest scale while never troubling a web one. A deployment that serves a native + app should raise the `perIp` values and leave `perIdentity` where it is. + +The defaults are the constants these limiters carried before the key existed, so an instance +that sets nothing behaves as it did. A partial value fills in the flows it leaves out. A changed +limit applies on the next request; a changed `windowSeconds` starts fresh counters. + +```json +{ "otp": { "perIp": 500 }, "magicLink": { "perIp": 500 } } +``` + ## Environment vs `system_config` Each env-mapped `system_config` row is seeded from its environment variable on first boot. After diff --git a/docs/ecosystem.md b/docs/ecosystem.md index 72c37e7..59c5e6a 100644 --- a/docs/ecosystem.md +++ b/docs/ecosystem.md @@ -54,7 +54,15 @@ The server-side adapter SDK; a thin stateless proxy + cookie manager. **Highest claim/field names (`sub`/`sid`/...), the `/refresh` response shape, or branch-significant status codes. -### `seamless-auth-react` — `@seamless-auth/react` (v0.11.0) +### `seamless-auth-react` — `@seamless-auth/client`, `@seamless-auth/react` (v0.12.0), `@seamless-auth/react-native` + +One repository, three packages. `@seamless-auth/client` is the framework-agnostic core: the +headless client, the session store, and the transport (cookie for browsers, bearer for native +apps, which mirrors the adapter's `x-seamless-auth-transport: bearer` contract and calls +`POST /auth/refresh`). `@seamless-auth/react` and `@seamless-auth/react-native` are thin bindings +over it. Route strings, parsed response fields and the `Omit<..., 'token' | 'sub'>` result +types all live in the client package now, so a contract change here ripples into one place and +both bindings pick it up. The notes below describe that shared surface. Drop-in React auth UI (email/phone OTP, magic link, WebAuthn/passkeys, OAuth, step-up, organizations). Hardcodes ~38 endpoint paths in `src/createSeamlessAuthClient.ts`. diff --git a/openapi.json b/openapi.json index a24548d..2eb5a57 100644 --- a/openapi.json +++ b/openapi.json @@ -1,6 +1,6 @@ { "openapi": "3.0.3", - "info": { "title": "Seamless Auth API", "version": "0.12.0" }, + "info": { "title": "Seamless Auth API", "version": "0.13.1" }, "components": { "schemas": {}, "parameters": {}, @@ -9733,6 +9733,7 @@ "max_concurrent_sessions": null, "rate_limit": 0, "delay_after": 0, + "flow_rate_limits": null, "rpid": "string", "origins": [null], "frontend_url": null, @@ -9911,6 +9912,77 @@ }, "rate_limit": { "type": "integer", "minimum": 0, "exclusiveMinimum": true }, "delay_after": { "type": "integer", "minimum": 0 }, + "flow_rate_limits": { + "type": "object", + "properties": { + "windowSeconds": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "default": 900 + }, + "otp": { + "type": "object", + "properties": { + "perIp": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "default": 10 + }, + "perIdentity": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "default": 5 + } + }, + "default": { "perIp": 10, "perIdentity": 5 } + }, + "magicLink": { + "type": "object", + "properties": { + "perIp": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "default": 20 + }, + "perIdentity": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "default": 5 + } + }, + "default": { "perIp": 20, "perIdentity": 5 } + }, + "oauth": { + "type": "object", + "properties": { + "perIp": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "default": 30 + }, + "perProvider": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "default": 10 + } + }, + "default": { "perIp": 30, "perProvider": 10 } + } + }, + "default": { + "windowSeconds": 900, + "otp": { "perIp": 10, "perIdentity": 5 }, + "magicLink": { "perIp": 20, "perIdentity": 5 }, + "oauth": { "perIp": 30, "perProvider": 10 } + } + }, "rpid": { "type": "string", "minLength": 1 }, "origins": { "type": "array", @@ -10141,6 +10213,71 @@ }, "rate_limit": { "type": "integer", "minimum": 0, "exclusiveMinimum": true }, "delay_after": { "type": "integer", "minimum": 0 }, + "flow_rate_limits": { + "type": "object", + "properties": { + "windowSeconds": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "default": 900 + }, + "otp": { + "type": "object", + "properties": { + "perIp": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "default": 10 + }, + "perIdentity": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "default": 5 + } + }, + "default": { "perIp": 10, "perIdentity": 5 } + }, + "magicLink": { + "type": "object", + "properties": { + "perIp": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "default": 20 + }, + "perIdentity": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "default": 5 + } + }, + "default": { "perIp": 20, "perIdentity": 5 } + }, + "oauth": { + "type": "object", + "properties": { + "perIp": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "default": 30 + }, + "perProvider": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "default": 10 + } + }, + "default": { "perIp": 30, "perProvider": 10 } + } + } + }, "rpid": { "type": "string", "minLength": 1 }, "origins": { "type": "array", diff --git a/package-lock.json b/package-lock.json index c271aeb..4787c1e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,19 @@ { "name": "seamless-auth-api", - "version": "0.12.0", + "version": "0.13.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "seamless-auth-api", - "version": "0.12.0", + "version": "0.13.2", "license": "AGPL-3.0-only", "dependencies": { "@asteasolutions/zod-to-openapi": "^9.1.0", "@seamless-auth/messaging": "^0.1.0", "@seamless-auth/messaging-aws": "^0.1.0", "@seamless-auth/messaging-twilio": "^0.1.0", - "@seamless-auth/types": "^0.21.0", + "@seamless-auth/types": "^0.22.0", "@simplewebauthn/server": "^13.3.3", "base64url": "^3.0.1", "bcrypt-ts": "^7.1.0", @@ -2997,9 +2997,9 @@ } }, "node_modules/@seamless-auth/types": { - "version": "0.21.0", - "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.21.0.tgz", - "integrity": "sha512-3n+a6W7OEUakONMhgQZcLr5Tkf5JJps1y1hplg41Dw23nrD+SXj2wfcZ7OvY5pHbuB68BjZAjgihs1P+3nO2+Q==", + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.22.0.tgz", + "integrity": "sha512-26MKBkZyj1hizuM5YiZ1Ho2BKDAv1yphzu/RiucQpKVBdr5XW8j7cQ0pqY/Ky+MgsLiBXtDIwleqh8FBOG/8Lg==", "license": "AGPL-3.0-only", "dependencies": { "zod": "^4.3.6" diff --git a/package.json b/package.json index 7a42833..37b2ec2 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "@seamless-auth/messaging": "^0.1.0", "@seamless-auth/messaging-aws": "^0.1.0", "@seamless-auth/messaging-twilio": "^0.1.0", - "@seamless-auth/types": "^0.21.0", + "@seamless-auth/types": "^0.22.0", "@simplewebauthn/server": "^13.3.3", "base64url": "^3.0.1", "bcrypt-ts": "^7.1.0", diff --git a/src/config/systemConfig.defaults.ts b/src/config/systemConfig.defaults.ts index 00da1ba..ed59156 100644 --- a/src/config/systemConfig.defaults.ts +++ b/src/config/systemConfig.defaults.ts @@ -4,7 +4,7 @@ * See LICENSE file in the project root for full license information */ -import { AuthenticatorPolicySchema } from '@seamless-auth/types'; +import { AuthenticatorPolicySchema, DefaultFlowRateLimits } from '@seamless-auth/types'; import type { SystemConfig } from '../schemas/systemConfig.schema.js'; @@ -33,4 +33,7 @@ export const SYSTEM_CONFIG_DEFAULTS: Partial = { // left absent so the key is settable from the environment like every other one. magic_link_redirect_uris: [], passkey_login_fallback_enabled: true, + // The constants the flow limiters carried in code before the key existed, so an + // instance that predates it keeps the limits it had. + flow_rate_limits: DefaultFlowRateLimits, }; diff --git a/src/config/systemConfig.envMap.ts b/src/config/systemConfig.envMap.ts index 6037ab3..d39fdb0 100644 --- a/src/config/systemConfig.envMap.ts +++ b/src/config/systemConfig.envMap.ts @@ -18,6 +18,7 @@ export const SYSTEM_CONFIG_ENV_MAP = { refresh_token_ttl: 'REFRESH_TOKEN_TTL', rate_limit: 'RATE_LIMIT', delay_after: 'DELAY_AFTER', + flow_rate_limits: 'FLOW_RATE_LIMITS', rpid: 'RPID', origins: 'ORIGINS', frontend_url: 'FRONTEND_URL', diff --git a/src/generated/api.ts b/src/generated/api.ts index e3a6e58..1867023 100644 --- a/src/generated/api.ts +++ b/src/generated/api.ts @@ -10223,6 +10223,7 @@ export interface paths { * "max_concurrent_sessions": null, * "rate_limit": 0, * "delay_after": 0, + * "flow_rate_limits": null, * "rpid": "string", * "origins": [ * null @@ -10340,6 +10341,63 @@ export interface paths { max_concurrent_sessions: number | null; rate_limit: number; delay_after: number; + /** + * @default { + * "windowSeconds": 900, + * "otp": { + * "perIp": 10, + * "perIdentity": 5 + * }, + * "magicLink": { + * "perIp": 20, + * "perIdentity": 5 + * }, + * "oauth": { + * "perIp": 30, + * "perProvider": 10 + * } + * } + */ + flow_rate_limits: { + /** @default 900 */ + windowSeconds: number; + /** + * @default { + * "perIp": 10, + * "perIdentity": 5 + * } + */ + otp: { + /** @default 10 */ + perIp: number; + /** @default 5 */ + perIdentity: number; + }; + /** + * @default { + * "perIp": 20, + * "perIdentity": 5 + * } + */ + magicLink: { + /** @default 20 */ + perIp: number; + /** @default 5 */ + perIdentity: number; + }; + /** + * @default { + * "perIp": 30, + * "perProvider": 10 + * } + */ + oauth: { + /** @default 30 */ + perIp: number; + /** @default 10 */ + perProvider: number; + }; + }; rpid: string; origins: string[]; /** Format: uri */ @@ -10507,6 +10565,46 @@ export interface paths { max_concurrent_sessions?: number | null; rate_limit?: number; delay_after?: number; + flow_rate_limits?: { + /** @default 900 */ + windowSeconds?: number; + /** + * @default { + * "perIp": 10, + * "perIdentity": 5 + * } + */ + otp?: { + /** @default 10 */ + perIp?: number; + /** @default 5 */ + perIdentity?: number; + }; + /** + * @default { + * "perIp": 20, + * "perIdentity": 5 + * } + */ + magicLink?: { + /** @default 20 */ + perIp?: number; + /** @default 5 */ + perIdentity?: number; + }; + /** + * @default { + * "perIp": 30, + * "perProvider": 10 + * } + */ + oauth?: { + /** @default 30 */ + perIp?: number; + /** @default 10 */ + perProvider?: number; + }; + }; rpid?: string; origins?: string[]; magic_link_redirect_uris?: string[]; diff --git a/src/middleware/rateLimit.ts b/src/middleware/rateLimit.ts index ddce333..ae24621 100644 --- a/src/middleware/rateLimit.ts +++ b/src/middleware/rateLimit.ts @@ -4,8 +4,9 @@ * See LICENSE file in the project root for full license information */ +import { DefaultFlowRateLimits } from '@seamless-auth/types'; import { NextFunction, Request, Response } from 'express'; -import rateLimit from 'express-rate-limit'; +import rateLimit, { RateLimitRequestHandler } from 'express-rate-limit'; import { getSystemConfig } from '../config/getSystemConfig.js'; import { AuthenticatedRequest } from '../types/types.js'; @@ -67,62 +68,66 @@ const dynamicLimiter = rateLimit({ message: TOO_MANY_REQUESTS_BODY, }); -const magicLinkIpCachedLimiter = rateLimit({ - windowMs: 15 * 60 * 1000, - limit: 20, - standardHeaders: true, - legacyHeaders: false, - skip: rateLimitsDisabled, - message: TOO_MANY_REQUESTS_BODY, -}); - -const magicLinkIdentityCachedLimiter = rateLimit({ - windowMs: 15 * 60 * 1000, - limit: 5, - keyGenerator: getMagicLinkIdentityKey, - standardHeaders: true, - legacyHeaders: false, - skip: rateLimitsDisabled, - message: TOO_MANY_REQUESTS_BODY, -}); +/** + * The per-flow limiters read their values from `flow_rate_limits` in system + * config rather than carrying constants, because the right per-IP value differs + * by audience: mobile carriers put thousands of subscribers behind one address, + * so a limit that never troubles a web audience refuses a mobile one. + * + * `express-rate-limit` takes the limit as a function but the window only as a + * number, so one limiter is built per configured window and kept. Changing the + * window starts fresh counters; changing a limit takes effect on the next hit. + */ +type FlowRateLimits = NonNullable>['flow_rate_limits']>; -const otpIpCachedLimiter = rateLimit({ - windowMs: 15 * 60 * 1000, - limit: 10, - standardHeaders: true, - legacyHeaders: false, - skip: rateLimitsDisabled, - message: TOO_MANY_REQUESTS_BODY, -}); +async function getFlowRateLimits(): Promise { + const { flow_rate_limits } = await getSystemConfig(); -const otpIdentityCachedLimiter = rateLimit({ - windowMs: 15 * 60 * 1000, - limit: 5, - keyGenerator: getOtpIdentityKey, - standardHeaders: true, - legacyHeaders: false, - skip: rateLimitsDisabled, - message: TOO_MANY_REQUESTS_BODY, -}); + return flow_rate_limits ?? DefaultFlowRateLimits; +} -const oauthIpCachedLimiter = rateLimit({ - windowMs: 15 * 60 * 1000, - limit: 30, - standardHeaders: true, - legacyHeaders: false, - skip: rateLimitsDisabled, - message: TOO_MANY_REQUESTS_BODY, -}); +function createFlowLimiter( + pick: (limits: FlowRateLimits) => number, + keyGenerator?: (req: Request) => string, +) { + const byWindow = new Map(); + + return async function flowLimiter(req: Request, res: Response, next: NextFunction) { + const windowMs = (await getFlowRateLimits()).windowSeconds * 1000; + + let limiter = byWindow.get(windowMs); + if (!limiter) { + limiter = rateLimit({ + windowMs, + limit: async () => pick(await getFlowRateLimits()), + ...(keyGenerator ? { keyGenerator } : {}), + standardHeaders: true, + legacyHeaders: false, + skip: rateLimitsDisabled, + message: TOO_MANY_REQUESTS_BODY, + }); + byWindow.set(windowMs, limiter); + } + + return limiter(req, res, next); + }; +} -const oauthProviderCachedLimiter = rateLimit({ - windowMs: 15 * 60 * 1000, - limit: 10, - keyGenerator: getOAuthFlowKey, - standardHeaders: true, - legacyHeaders: false, - skip: rateLimitsDisabled, - message: TOO_MANY_REQUESTS_BODY, -}); +const magicLinkIpCachedLimiter = createFlowLimiter((limits) => limits.magicLink.perIp); +const magicLinkIdentityCachedLimiter = createFlowLimiter( + (limits) => limits.magicLink.perIdentity, + getMagicLinkIdentityKey, +); +const otpIpCachedLimiter = createFlowLimiter((limits) => limits.otp.perIp); +const otpIdentityCachedLimiter = createFlowLimiter( + (limits) => limits.otp.perIdentity, + getOtpIdentityKey, +); +const oauthIpCachedLimiter = createFlowLimiter((limits) => limits.oauth.perIp); +const oauthProviderCachedLimiter = createFlowLimiter( + (limits) => limits.oauth.perProvider, + getOAuthFlowKey, +); export function dynamicRateLimit(req: Request, res: Response, next: NextFunction) { return dynamicLimiter(req, res, next); diff --git a/src/schemas/systemConfig.schema.ts b/src/schemas/systemConfig.schema.ts index 30f02d7..57b24d4 100644 --- a/src/schemas/systemConfig.schema.ts +++ b/src/schemas/systemConfig.schema.ts @@ -4,7 +4,12 @@ * See LICENSE file in the project root for full license information */ -export type { LockoutPolicy, OAuthProviderConfig, SystemConfig } from '@seamless-auth/types'; +export type { + FlowRateLimits, + LockoutPolicy, + OAuthProviderConfig, + SystemConfig, +} from '@seamless-auth/types'; export { LoginMethodSchema, OAuthProviderConfigSchema, diff --git a/src/utils/parseEnvConfigs.ts b/src/utils/parseEnvConfigs.ts index 8b627a0..7174ba2 100644 --- a/src/utils/parseEnvConfigs.ts +++ b/src/utils/parseEnvConfigs.ts @@ -26,6 +26,7 @@ export function parseSystemConfigEnvValue(key: keyof typeof SYSTEM_CONFIG_ENV_MA case 'lockout_policy': case 'authenticator_policy': + case 'flow_rate_limits': return JSON.parse(raw); case 'rate_limit': diff --git a/tests/unit/config/systemConfigEnvMap.spec.ts b/tests/unit/config/systemConfigEnvMap.spec.ts index d634ad6..699d490 100644 --- a/tests/unit/config/systemConfigEnvMap.spec.ts +++ b/tests/unit/config/systemConfigEnvMap.spec.ts @@ -26,6 +26,7 @@ describe('SYSTEM_CONFIG_ENV_MAP', () => { max_concurrent_sessions: 'MAX_CONCURRENT_SESSIONS', rate_limit: 'RATE_LIMIT', delay_after: 'DELAY_AFTER', + flow_rate_limits: 'FLOW_RATE_LIMITS', rpid: 'RPID', origins: 'ORIGINS', frontend_url: 'FRONTEND_URL', diff --git a/tests/unit/middleware/rateLimit.spec.ts b/tests/unit/middleware/rateLimit.spec.ts index 396e04b..2d21028 100644 --- a/tests/unit/middleware/rateLimit.spec.ts +++ b/tests/unit/middleware/rateLimit.spec.ts @@ -162,76 +162,144 @@ describe('dynamicRateLimit', () => { expect(next).toHaveBeenCalled(); }); - it('creates limiter instances once at module initialization', async () => { + it('creates the general limiter once at module initialization', async () => { const { getSystemConfig } = await import('../../../src/config/getSystemConfig'); const rateLimit = await import('express-rate-limit'); - (getSystemConfig as any).mockResolvedValue({ rate_limit: 100 }); - const { dynamicRateLimit } = await import('../../../src/middleware/rateLimit'); await dynamicRateLimit(req, res, next); await dynamicRateLimit(req, res, next); - expect(rateLimit.default).toHaveBeenCalledTimes(7); + // The flow limiters are built on first use, per configured window, not here. + expect(rateLimit.default).toHaveBeenCalledTimes(1); }); }); -describe('magicLinkIpLimiter', () => { - it('uses fixed limit of 20', async () => { - const { getSystemConfig } = await import('../../../src/config/getSystemConfig'); - const rateLimit = await import('express-rate-limit'); +// The six flow limiters read `flow_rate_limits` from system config. With no +// value configured they carry the constants they had before the key existed. +const FLOW_LIMITS = { + windowSeconds: 900, + otp: { perIp: 10, perIdentity: 5 }, + magicLink: { perIp: 20, perIdentity: 5 }, + oauth: { perIp: 30, perProvider: 10 }, +}; + +async function loadFlowLimiters(config: Record = {}) { + const { getSystemConfig } = await import('../../../src/config/getSystemConfig'); + const rateLimit = await import('express-rate-limit'); + (getSystemConfig as any).mockResolvedValue(config); + const limiters = await import('../../../src/middleware/rateLimit'); + return { getSystemConfig, rateLimit, limiters }; +} + +/** The options of the last limiter express-rate-limit was asked to build. */ +function lastOptions(rateLimit: any) { + const calls = rateLimit.default.mock.calls; + return calls[calls.length - 1][0]; +} + +describe('flow limiters', () => { + it.each([ + ['magicLinkIpLimiter', 20], + ['magicLinkEmailLimiter', 5], + ['otpIpLimiter', 10], + ['otpIdentityLimiter', 5], + ['oauthIpLimiter', 30], + ['oauthProviderLimiter', 10], + ] as const)( + '%s keeps its historical limit when nothing is configured', + async (name, expected) => { + const { rateLimit, limiters } = await loadFlowLimiters(); + const next = vi.fn(); + + // @ts-ignore + await limiters[name]({ ip: '1.1.1.1', params: {} }, {}, next); + + const options = lastOptions(rateLimit); + expect(options).toEqual( + expect.objectContaining({ + legacyHeaders: false, + standardHeaders: true, + windowMs: 15 * 60 * 1000, + limit: expect.any(Function), + }), + ); + await expect(options.limit()).resolves.toBe(expected); + expect(next).toHaveBeenCalled(); + }, + ); + + it('reads the configured per-IP value for a flow and leaves the others alone', async () => { + const { rateLimit, limiters } = await loadFlowLimiters({ + flow_rate_limits: { ...FLOW_LIMITS, otp: { perIp: 500, perIdentity: 5 } }, + }); - (getSystemConfig as any).mockResolvedValue({}); + // @ts-ignore + await limiters.otpIpLimiter({}, {}, vi.fn()); + const otpIp = lastOptions(rateLimit); + // @ts-ignore + await limiters.otpIdentityLimiter({ body: { email: 'a@b.c' } }, {}, vi.fn()); + const otpIdentity = lastOptions(rateLimit); - const { magicLinkIpLimiter } = await import('../../../src/middleware/rateLimit'); + await expect(otpIp.limit()).resolves.toBe(500); + await expect(otpIdentity.limit()).resolves.toBe(5); + }); - const next = vi.fn(); + it('takes a changed limit on the next hit without rebuilding the limiter', async () => { + const { getSystemConfig, rateLimit, limiters } = await loadFlowLimiters({ + flow_rate_limits: FLOW_LIMITS, + }); // @ts-ignore - await magicLinkIpLimiter({}, {}, next); + await limiters.magicLinkIpLimiter({}, {}, vi.fn()); + const built = rateLimit.default.mock.calls.length; + const options = lastOptions(rateLimit); - expect(rateLimit.default).toHaveBeenCalledWith( - expect.objectContaining({ - limit: 20, - }), - ); + (getSystemConfig as any).mockResolvedValue({ + flow_rate_limits: { ...FLOW_LIMITS, magicLink: { perIp: 75, perIdentity: 5 } }, + }); + // @ts-ignore + await limiters.magicLinkIpLimiter({}, {}, vi.fn()); + + expect(rateLimit.default.mock.calls.length).toBe(built); + await expect(options.limit()).resolves.toBe(75); }); -}); -describe('magicLinkEmailLimiter', () => { - it('uses authenticated email or ip as key', async () => { - const { getSystemConfig } = await import('../../../src/config/getSystemConfig'); - const rateLimit = await import('express-rate-limit'); + it('builds one limiter per configured window', async () => { + const { getSystemConfig, rateLimit, limiters } = await loadFlowLimiters({ + flow_rate_limits: FLOW_LIMITS, + }); - (getSystemConfig as any).mockResolvedValue({}); + // @ts-ignore + await limiters.oauthIpLimiter({}, {}, vi.fn()); + // @ts-ignore + await limiters.oauthIpLimiter({}, {}, vi.fn()); + const withDefaultWindow = rateLimit.default.mock.calls.length; - const { magicLinkEmailLimiter } = await import('../../../src/middleware/rateLimit'); + (getSystemConfig as any).mockResolvedValue({ + flow_rate_limits: { ...FLOW_LIMITS, windowSeconds: 60 }, + }); + // @ts-ignore + await limiters.oauthIpLimiter({}, {}, vi.fn()); + + expect(rateLimit.default.mock.calls.length).toBe(withDefaultWindow + 1); + expect(lastOptions(rateLimit).windowMs).toBe(60 * 1000); + }); +}); +describe('magicLinkEmailLimiter', () => { + it('uses authenticated email or ip as key', async () => { + const { rateLimit, limiters } = await loadFlowLimiters(); const req: any = { user: { email: 'Test@Example.com' }, ip: '127.0.0.1', }; - const next = vi.fn(); - // @ts-ignore - await magicLinkEmailLimiter(req, {}, next); - - expect(rateLimit.default).toHaveBeenCalledWith( - expect.objectContaining({ - keyGenerator: expect.any(Function), - legacyHeaders: false, - limit: 5, - standardHeaders: true, - windowMs: 15 * 60 * 1000, - }), - ); - - const options = (rateLimit.default as any).mock.calls.find( - ([options]: any[]) => options.keyGenerator, - )[0]; + await limiters.magicLinkEmailLimiter(req, {}, vi.fn()); + const options = lastOptions(rateLimit); expect(options.keyGenerator(req)).toBe('email:test@example.com'); expect(options.keyGenerator({ ip: '127.0.0.1' })).toBe('ip:127.0.0.1'); }); @@ -239,96 +307,35 @@ describe('magicLinkEmailLimiter', () => { describe('otpIdentityLimiter', () => { it('uses authenticated email or phone as key', async () => { - const { getSystemConfig } = await import('../../../src/config/getSystemConfig'); - const rateLimit = await import('express-rate-limit'); - - (getSystemConfig as any).mockResolvedValue({}); - - const { otpIdentityLimiter } = await import('../../../src/middleware/rateLimit'); - + const { rateLimit, limiters } = await loadFlowLimiters(); const req: any = { user: { email: null, phone: '+14155552671' }, ip: '127.0.0.1', }; - const next = vi.fn(); // @ts-ignore - await otpIdentityLimiter(req, {}, next); + await limiters.otpIdentityLimiter(req, {}, vi.fn()); - const options = (rateLimit.default as any).mock.calls - .map(([options]: any[]) => options) - .find((options: any) => options.keyGenerator?.(req) === 'phone:+14155552671'); - - expect(options).toEqual( - expect.objectContaining({ - keyGenerator: expect.any(Function), - legacyHeaders: false, - limit: 5, - standardHeaders: true, - windowMs: 15 * 60 * 1000, - }), - ); + const options = lastOptions(rateLimit); + expect(options.keyGenerator(req)).toBe('phone:+14155552671'); expect(options.keyGenerator({ user: { email: 'Test@Example.com' } })).toBe( 'email:test@example.com', ); }); }); -describe('otpIpLimiter', () => { - it('invokes the fixed IP-based OTP limiter and continues', async () => { - const { getSystemConfig } = await import('../../../src/config/getSystemConfig'); - const rateLimit = await import('express-rate-limit'); - - (getSystemConfig as any).mockResolvedValue({}); - - const { otpIpLimiter } = await import('../../../src/middleware/rateLimit'); - const next = vi.fn(); - - // @ts-ignore - await otpIpLimiter({}, {}, next); - - expect((rateLimit.default as any).mock.calls[3][0]).toEqual( - expect.objectContaining({ limit: 10 }), - ); - expect(next).toHaveBeenCalled(); - }); -}); - -describe('oauthIpLimiter', () => { - it('invokes the fixed IP-based OAuth limiter and continues', async () => { - const { getSystemConfig } = await import('../../../src/config/getSystemConfig'); - const rateLimit = await import('express-rate-limit'); - - (getSystemConfig as any).mockResolvedValue({}); - - const { oauthIpLimiter } = await import('../../../src/middleware/rateLimit'); - const next = vi.fn(); - - // @ts-ignore - await oauthIpLimiter({}, {}, next); - - expect((rateLimit.default as any).mock.calls[5][0]).toEqual( - expect.objectContaining({ limit: 30 }), - ); - expect(next).toHaveBeenCalled(); - }); -}); - describe('rate limiter key generators', () => { async function keyGenerators() { - const { getSystemConfig } = await import('../../../src/config/getSystemConfig'); - const rateLimit = await import('express-rate-limit'); - - (getSystemConfig as any).mockResolvedValue({}); - - await import('../../../src/middleware/rateLimit'); - - const calls = (rateLimit.default as any).mock.calls; + const { rateLimit, limiters } = await loadFlowLimiters(); + const keyOf = async (limiter: any) => { + await limiter({ params: {} }, {}, vi.fn()); + return lastOptions(rateLimit).keyGenerator; + }; return { - magicLink: calls[2][0].keyGenerator, - otp: calls[4][0].keyGenerator, - oauth: calls[6][0].keyGenerator, + magicLink: await keyOf(limiters.magicLinkEmailLimiter), + otp: await keyOf(limiters.otpIdentityLimiter), + oauth: await keyOf(limiters.oauthProviderLimiter), }; } @@ -362,35 +369,16 @@ describe('rate limiter key generators', () => { describe('oauthProviderLimiter', () => { it('keys by provider and ip', async () => { - const { getSystemConfig } = await import('../../../src/config/getSystemConfig'); - const rateLimit = await import('express-rate-limit'); - - (getSystemConfig as any).mockResolvedValue({}); - - const { oauthProviderLimiter } = await import('../../../src/middleware/rateLimit'); - + const { rateLimit, limiters } = await loadFlowLimiters(); const req: any = { params: { providerId: 'google' }, ip: '127.0.0.1', }; - const next = vi.fn(); // @ts-ignore - await oauthProviderLimiter(req, {}, next); - - const options = (rateLimit.default as any).mock.calls - .map(([options]: any[]) => options) - .find((options: any) => options.keyGenerator?.(req) === 'google:127.0.0.1'); + await limiters.oauthProviderLimiter(req, {}, vi.fn()); - expect(options).toEqual( - expect.objectContaining({ - keyGenerator: expect.any(Function), - legacyHeaders: false, - limit: 10, - standardHeaders: true, - windowMs: 15 * 60 * 1000, - }), - ); + expect(lastOptions(rateLimit).keyGenerator(req)).toBe('google:127.0.0.1'); }); }); @@ -399,18 +387,24 @@ describe('refusal body', () => { // default, so grepping for the string found three of the nine sites. Asserted across // every constructed limiter rather than per-limiter for that reason. it('gives every limiter the JSON error shape', async () => { - const { getSystemConfig } = await import('../../../src/config/getSystemConfig'); - const rateLimit = await import('express-rate-limit'); - - (getSystemConfig as any).mockResolvedValue({}); - - await import('../../../src/middleware/rateLimit'); + const { limiters, rateLimit } = await loadFlowLimiters(); await import('../../../src/middleware/jwksRateLimit'); + for (const name of [ + 'magicLinkIpLimiter', + 'magicLinkEmailLimiter', + 'otpIpLimiter', + 'otpIdentityLimiter', + 'oauthIpLimiter', + 'oauthProviderLimiter', + ] as const) { + // @ts-ignore + await limiters[name]({ ip: '1.1.1.1', params: {} }, {}, vi.fn()); + } + const messages = (rateLimit.default as any).mock.calls.map( ([options]: any[]) => options.message, ); - expect(messages).toHaveLength(8); for (const message of messages) { expect(message).toEqual({ error: 'Too many requests, please try again later' }); @@ -459,7 +453,7 @@ describe('dynamicJWKSRateLimit', () => { }); describe('rate limiter caches', () => { - it('keeps dynamic and magic link limiter instances isolated', async () => { + it('keeps the general limiter and each flow limiter as separate instances', async () => { const { getSystemConfig } = await import('../../../src/config/getSystemConfig'); const rateLimit = await import('express-rate-limit'); @@ -476,21 +470,15 @@ describe('rate limiter caches', () => { await magicLinkIpLimiter({}, {}, next); // @ts-ignore await magicLinkEmailLimiter({}, {}, next); + // @ts-ignore + await magicLinkIpLimiter({}, {}, next); - expect(rateLimit.default).toHaveBeenCalledTimes(7); - expect((rateLimit.default as any).mock.calls[0][0]).toEqual( - expect.objectContaining({ - limit: expect.any(Function), - }), + // One general limiter at load, then one per flow limiter on first use, and a + // repeat hit reuses the instance it built. + expect(rateLimit.default).toHaveBeenCalledTimes(3); + const limits = await Promise.all( + (rateLimit.default as any).mock.calls.map(([options]: any[]) => options.limit()), ); - expect((rateLimit.default as any).mock.calls.map(([options]: any[]) => options.limit)).toEqual([ - expect.any(Function), - 20, - 5, - 10, - 5, - 30, - 10, - ]); + expect(limits).toEqual([100, 20, 5]); }); }); diff --git a/tests/unit/utils/parseSystemConfigEnvValue.spec.ts b/tests/unit/utils/parseSystemConfigEnvValue.spec.ts index 2aa99a9..022136c 100644 --- a/tests/unit/utils/parseSystemConfigEnvValue.spec.ts +++ b/tests/unit/utils/parseSystemConfigEnvValue.spec.ts @@ -135,6 +135,20 @@ describe('parseSystemConfigEnvValue', () => { }); }); + describe('flow_rate_limits parsing', () => { + it('parses the flow rate limits JSON object', () => { + const raw = JSON.stringify({ + windowSeconds: 600, + otp: { perIp: 200, perIdentity: 5 }, + }); + + expect(parseSystemConfigEnvValue('flow_rate_limits', raw)).toEqual({ + windowSeconds: 600, + otp: { perIp: 200, perIdentity: 5 }, + }); + }); + }); + describe('authenticator_policy parsing', () => { it('parses the authenticator policy JSON object', () => { const raw = JSON.stringify({ attachment: 'cross-platform' });