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
21 changes: 21 additions & 0 deletions .changeset/flow-rate-limits.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@seamless-auth/types': minor
---

Add `flow_rate_limits` to the system config, and pin the Android WebAuthn origin form.

`seamless-auth-api` limits how often one caller may start an OTP, magic link, or OAuth flow, on
top of the general `rate_limit`, with constants fixed in code: 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 are fine for a
web audience and wrong for a mobile one, because carriers put thousands of subscribers behind one
address. `flow_rate_limits` is an object key (`windowSeconds`, `otp.perIp`, `otp.perIdentity`,
`magicLink.perIp`, `magicLink.perIdentity`, `oauth.perIp`, `oauth.perProvider`) whose defaults
are exactly those constants, so a deployment that sets nothing behaves as it did, and a partial
value fills the flows it leaves out. `FlowRateLimitsSchema`, `FlowRateLimits` and
`DefaultFlowRateLimits` are exported; the patch schema accepts the key.

`origins` gains a test and a comment for the form Android reports, `android:apk-key-hash:<base64url>`,
which `z.url()` accepts as an opaque URL. It has to keep accepting it for native passkeys to
verify, and nothing said so until now.

Part of the mobile track, fells-code/seamless-templates#40.
40 changes: 40 additions & 0 deletions src/schemas/systemConfig/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,46 @@ describe('SystemConfigSchema', () => {
it('rejects a non-URL origin', () => {
expect(() => SystemConfigSchema.parse({ ...baseConfig, origins: ['example.com'] })).toThrow();
});

// Android reports a WebAuthn origin with no host, and native passkeys verify
// against it. Pinned so a stricter origin check cannot quietly break them.
it('accepts the Android APK key hash origin form', () => {
const origins = [
'https://example.com',
'android:apk-key-hash:pNiP5iKyQ8JwgGOaKA1zGPUPJIS-0H1xKCQcfIoGLck',
];

expect(SystemConfigSchema.parse({ ...baseConfig, origins }).origins).toEqual(origins);
});

it('applies the historical constants as the default flow rate limits', () => {
expect(SystemConfigSchema.parse(baseConfig).flow_rate_limits).toEqual({
windowSeconds: 900,
otp: { perIp: 10, perIdentity: 5 },
magicLink: { perIp: 20, perIdentity: 5 },
oauth: { perIp: 30, perProvider: 10 },
});
});

it('fills the flows a partial flow_rate_limits leaves out', () => {
const parsed = SystemConfigSchema.parse({
...baseConfig,
flow_rate_limits: { otp: { perIp: 200 } },
});

expect(parsed.flow_rate_limits.otp).toEqual({ perIp: 200, perIdentity: 5 });
expect(parsed.flow_rate_limits.magicLink).toEqual({ perIp: 20, perIdentity: 5 });
expect(parsed.flow_rate_limits.windowSeconds).toBe(900);
});

it.each([
[{ otp: { perIp: 0 } }],
[{ magicLink: { perIdentity: -1 } }],
[{ windowSeconds: 1.5 }],
[{ oauth: { perProvider: 'many' } }],
])('rejects the flow rate limit %j', (flow_rate_limits) => {
expect(() => SystemConfigSchema.parse({ ...baseConfig, flow_rate_limits })).toThrow();
});
});

describe('SystemConfigPatchSchema', () => {
Expand Down
64 changes: 64 additions & 0 deletions src/schemas/systemConfig/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,59 @@ export const DefaultLockoutPolicy = {
lockoutSeconds: 15 * 60,
} as const;

const PerIpLimitSchema = z.number().int().positive();

/**
* How many times one caller may start a message-carrying or provider flow per
* window, before the general `rate_limit` applies to everything else.
*
* Two keys per flow because they guard different things. `perIdentity` bounds
* how often one address or phone can be messaged, which is the abuse a sender
* cares about. `perIp` bounds how many distinct addresses one network location
* can drive, which is the enumeration and spam guard. The per-IP values were
* fixed constants before this key existed, and those constants are the defaults
* here, so a deployment that sets nothing behaves as it did.
*
* Per-IP limits are the ones a native app runs into first. Mobile carriers put
* thousands of subscribers behind one address, so a value that never troubles a
* web audience can refuse a mobile audience at modest scale. Raise `perIp` for a
* deployment that serves phones; leave `perIdentity` where it is.
*/
export const FlowRateLimitsSchema = z.object({
windowSeconds: z
.number()
.int()
.positive()
.default(15 * 60),
otp: z
.object({
perIp: PerIpLimitSchema.default(10),
perIdentity: PerIpLimitSchema.default(5),
})
.default({ perIp: 10, perIdentity: 5 }),
magicLink: z
.object({
perIp: PerIpLimitSchema.default(20),
perIdentity: PerIpLimitSchema.default(5),
})
.default({ perIp: 20, perIdentity: 5 }),
oauth: z
.object({
perIp: PerIpLimitSchema.default(30),
perProvider: PerIpLimitSchema.default(10),
})
.default({ perIp: 30, perProvider: 10 }),
});

export type FlowRateLimits = z.infer<typeof FlowRateLimitsSchema>;

export const DefaultFlowRateLimits = {
windowSeconds: 15 * 60,
otp: { perIp: 10, perIdentity: 5 },
magicLink: { perIp: 20, perIdentity: 5 },
oauth: { perIp: 30, perProvider: 10 },
} as const;

export const AuthenticatorAttachmentPolicySchema = z.enum(['any', 'platform', 'cross-platform']);

export type AuthenticatorAttachmentPolicy = z.infer<typeof AuthenticatorAttachmentPolicySchema>;
Expand Down Expand Up @@ -247,8 +300,18 @@ export const SystemConfigSchema = z.object({

rate_limit: z.number().int().positive(),
delay_after: z.number().int().nonnegative(),
flow_rate_limits: FlowRateLimitsSchema.default(DefaultFlowRateLimits),

rpid: z.string().min(1),
/**
* The WebAuthn origins this deployment accepts, and the fallback for the magic
* link and OAuth destinations (`origins[0]`), so the web origin belongs first.
*
* `z.url()` here is deliberately loose. An Android app's WebAuthn origin is
* `android:apk-key-hash:<base64url>`, which is a valid URL with an opaque path
* and no host, and it must be accepted here for native passkeys to verify. Do
* not tighten this to a hostname check without carrying that form along.
*/
origins: z.array(z.url()).min(1),

frontend_url: z.url().optional(),
Expand Down Expand Up @@ -284,6 +347,7 @@ export const SystemConfigPatchSchema = z
max_concurrent_sessions: z.number().int().positive().nullable().optional(),
rate_limit: SystemConfigSchema.shape.rate_limit.optional(),
delay_after: SystemConfigSchema.shape.delay_after.optional(),
flow_rate_limits: FlowRateLimitsSchema.optional(),
rpid: SystemConfigSchema.shape.rpid.optional(),
origins: SystemConfigSchema.shape.origins.optional(),
magic_link_redirect_uris: z.array(RedirectTargetSchema).optional(),
Expand Down