Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .changeset/flow-rate-limits.md
Original file line number Diff line number Diff line change
@@ -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`.
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions docs/api-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://<rpid>`, so that exact
origin belongs in `origins` even when the web app lives on a subdomain. Android reports
`android:apk-key-hash:<base64url of the signing certificate's SHA-256>`, 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
Expand Down
24 changes: 24 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion docs/ecosystem.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
139 changes: 138 additions & 1 deletion openapi.json
Original file line number Diff line number Diff line change
@@ -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": {},
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
12 changes: 6 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 4 additions & 1 deletion src/config/systemConfig.defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -33,4 +33,7 @@ export const SYSTEM_CONFIG_DEFAULTS: Partial<SystemConfig> = {
// 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,
};
1 change: 1 addition & 0 deletions src/config/systemConfig.envMap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading
Loading