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
10 changes: 10 additions & 0 deletions .changeset/attempt-scoped-flow-limits.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'seamless-auth-api': patch
---

OTP and magic link limits and links are scoped to the sign-in attempt, so a stranger who knows an address can no longer use up or void the owner's codes and links (#468).

- The OTP and magic link identity limiters key `flow_rate_limits.*.perIdentity` on the identifier plus the ephemeral token's attempt (`jti`), or the requester's IP when there is none. Another attempt for the same address keeps its own allowance.
- Every attempt for one identifier together is capped at four times `perIdentity` (20 per window by default), so minting fresh attempts still cannot message an address without bound.
- `POST /magic-link` expires only earlier links from the same attempt instead of every link for the account.
- `FLOW_RATE_LIMITS` / `flow_rate_limits` keep their shape and meaning. No new config key.
11 changes: 11 additions & 0 deletions .changeset/registration-otp-login-methods.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'seamless-auth-api': patch
---

The registration email OTP path honours `login_methods` for accounts that are already verified (#470).

- `POST /otp/verify-email-otp` signs an already verified account in only when `email_otp` is a login method (and the passkey rules allow it). Otherwise it answers `403 login_method_disabled` before checking the code, the same answer as `/otp/verify-login-email-otp`. Before, a deployment that removed `email_otp` from `login_methods` could still be signed in to with an email code through this endpoint.
- `POST /otp/generate-email-otp` (and its deprecated `GET`) refuses an already verified account the same way, and its OpenAPI responses now list `403`.
- An unverified account still receives and verifies its registration code whatever `login_methods` says.
- The session is marked as an account verification only when the code actually moved the account from unverified to verified, so the phishing-resistant-only backstop in session issuance stays in force for an existing account.
- Decoy continuations answer both endpoints the same way a verified account is answered.
5 changes: 5 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,11 @@ Supported login methods are controlled by `login_methods` system config:
- `phone_otp`
- `oauth`

Email OTP is also how registration proves an address, so `/otp/generate-email-otp` and
`/otp/verify-email-otp` serve an unverified account whatever `login_methods` says. For an account
that is already verified those two endpoints are a sign-in, and they answer
`403 login_method_disabled` unless `email_otp` is listed (and the passkey rules below allow it).

Accounts holding a passkey can be restricted to passkey-only continuation by disabling
`passkey_login_fallback_enabled`. `phishing_resistant_only` goes further and restricts every
account to passkeys, apart from the one session that verifies a new account's address. Both
Expand Down
10 changes: 7 additions & 3 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -430,9 +430,13 @@ Validation is enforced by [`systemConfig.schema.ts`](../src/schemas/systemConfig
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.
- `perIdentity` bounds how often one sign-in attempt can message one address or phone (`otp`,
`magicLink`), keyed on the identifier plus the ephemeral token's attempt (`jti`, or the
requester's IP without one), and how often one provider can be started from one address
(`oauth.perProvider`). All attempts for one identifier together are capped at four times
`perIdentity`, so a stranger starting attempts for someone else's address cannot use up the
owner's allowance ([security posture](./security-posture.md#code-and-link-sends-per-address)).
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
Expand Down
30 changes: 28 additions & 2 deletions docs/security-posture.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,8 +204,9 @@ Ephemeral tokens ([`src/lib/token.ts`](../src/lib/token.ts)) are validated by si
but are not single-use, so a leaked one can be replayed until it expires.

The window is 5 minutes. Every continuation endpoint that accepts an ephemeral token is behind the
OTP, magic-link, or registration rate limiters, which are keyed per IP and per identity, so replay
cannot be used to generate OTPs in bulk. An ephemeral token also cannot be exchanged for anything
OTP, magic-link, or registration rate limiters, which are keyed per IP, per identity and attempt,
and per identity across attempts (see [Code and link sends per address](#code-and-link-sends-per-address)),
so replay cannot be used to generate OTPs in bulk. An ephemeral token also cannot be exchanged for anything
on its own: it authorizes a continuation step that still requires the OTP code, the magic-link
token, or a WebAuthn assertion.

Expand Down Expand Up @@ -485,6 +486,31 @@ factor. Enrolling a passkey keeps a user able to sign in through a lock someone
The counter lives in Postgres, so every instance of a deployment sees the same count. When it cannot
be read, the account is treated as locked (see [An unknown count means locked](#an-unknown-count-means-locked)).

## Code and link sends per address

**Posture: one attempt cannot spend or void another attempt's codes or links.**

The same thing that makes lockout a denial-of-service lever applies to message sends: `/login`
mints an ephemeral token from an address alone, so anyone can start attempts for someone else's
address. Previously the OTP and magic link identity limiters keyed on the address only, and a
magic link request expired every earlier link for the account, so a stranger could keep those
methods unavailable to the owner.

- **Per attempt.** `flow_rate_limits.otp.perIdentity` and `magicLink.perIdentity` (default 5 per
15 minutes) bound one attempt for one address: the address plus the ephemeral token's `jti`,
or the requester's IP for a request without one. Another attempt for the same address has its
own allowance.
- **Per address.** Every attempt for one address together is capped at four times `perIdentity`
(20 by default), so minting fresh attempts still cannot message an address without bound. With
the default limits a single network location cannot reach the OTP cap, because `otp.perIp` is
10; the magic link cap equals `magicLink.perIp`, so one location can just reach it.
- **Links.** Requesting a magic link expires only earlier links from the same attempt. A link
another attempt is waiting on stays usable until it expires or is used.

What remains: a stranger with several network locations can still fill the per-address cap for the
window, and the owner then waits it out, as with lockout. Wrong codes from every attempt still
count toward lockout together, because lockout is per account (see [Account lockout](#account-lockout)).

## Audit write failure

**Posture: the trail may be lost, no security control may be.**
Expand Down
26 changes: 26 additions & 0 deletions openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -9973,6 +9973,19 @@
}
}
},
"403": {
"description": "HTTP 403",
"content": {
"application/json": {
"example": { "message": "string", "error": "string" },
"schema": {
"type": "object",
"properties": { "message": { "type": "string" }, "error": { "type": "string" } },
"required": ["error"]
}
}
}
},
"429": {
"description": "HTTP 429",
"content": {
Expand Down Expand Up @@ -10078,6 +10091,19 @@
}
}
},
"403": {
"description": "HTTP 403",
"content": {
"application/json": {
"example": { "message": "string", "error": "string" },
"schema": {
"type": "object",
"properties": { "message": { "type": "string" }, "error": { "type": "string" } },
"required": ["error"]
}
}
}
},
"429": {
"description": "HTTP 429",
"content": {
Expand Down
8 changes: 4 additions & 4 deletions resources/coverage-badge.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
15 changes: 10 additions & 5 deletions src/controllers/decoyResponders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,13 @@ async function respondOtpSent(req: Request, res: Response, kind: 'otp_email' | '
return res.status(200).json({ message: 'success', token });
}

export const decoySendEmailOtp = (req: Request, res: Response) =>
respondOtpSent(req, res, 'otp_email');
export const decoySendEmailOtp = async (req: Request, res: Response) => {
if (await rejectDisabledMethod('email_otp', req, res)) {
return;
}

return respondOtpSent(req, res, 'otp_email');
};

export const decoySendPhoneOtp = (req: Request, res: Response) =>
respondOtpSent(req, res, 'otp_sms');
Expand Down Expand Up @@ -157,8 +162,8 @@ async function respondOtpVerifyFailed(req: Request, res: Response) {
}

/**
* Mirrors the passkey rule on the paths a real verified account cannot use once a passkey
* is required: email verification, which signs such an account in, and TOTP login.
* Mirrors the passkey rule on TOTP login, which a real verified account cannot use once a
* passkey is required.
*/
async function rejectPasskeyRequired(method: string, req: Request, res: Response) {
const policy = await getLoginPolicy();
Expand All @@ -179,7 +184,7 @@ async function rejectPasskeyRequired(method: string, req: Request, res: Response
}

export const decoyVerifyEmailOtp = async (req: Request, res: Response) => {
if (await rejectPasskeyRequired('email_otp', req, res)) {
if (await rejectDisabledMethod('email_otp', req, res)) {
return;
}

Expand Down
4 changes: 3 additions & 1 deletion src/controllers/magicLinks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,12 +123,14 @@ export async function requestMagicLink(req: MagicLinkRequest, res: Response) {
await logMagicLinkFailure(req, 'Ephemeral token has no attempt id', user.id);
return res.status(400).json({ error: 'Invalid request' });
}
// Expire all previous links
// Only this attempt's earlier links. Anyone who knows the address can start an attempt,
// so expiring every link for the user would let them void the one the owner is opening.
await MagicLinkToken.update(
{ expires_at: new Date() },
{
where: {
user_id: user.id,
attempt_id: authReq.attemptId,
},
},
);
Expand Down
36 changes: 20 additions & 16 deletions src/controllers/otp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,16 @@ export const sendLoginPhoneOTP = async (req: Request, res: Response) => {
return sendPhoneOTP(req, res);
};

export const sendRegistrationEmailOTP = async (req: Request, res: Response) => {
const user = (req as AuthenticatedRequest).user;

if (user.verified && (await rejectDisabledLoginMethod('email_otp', req, res))) {
return;
}

return sendEmailOTP(req, res);
};

export const sendLoginEmailOTP = async (req: Request, res: Response) => {
if (await rejectDisabledLoginMethod('email_otp', req, res)) {
return;
Expand Down Expand Up @@ -312,25 +322,19 @@ export const verifyEmail = async (req: Request, res: Response) => {
let user = authReq.user;
const email = user.email;

// This endpoint issues a session for an already-verified account, so it is a login
// whatever its name says, and the lockout policy has to bind here too. Without it,
// an account locked out of /otp/verify-login-email-otp could still authenticate
// through this one. The login-method list deliberately does not gate it: email OTP
// is how registration proves an address, whether or not the deployment offers it as
// a way to sign in. A rule that requires a passkey does, once the account is already
// verified, or this endpoint would be the way around it.
// For an account that was already verified this endpoint is a login whatever its name
// says, so the lockout policy and the login method list bind here as they do on
// /otp/verify-login-email-otp. Only an unverified account skips the method list, since
// email OTP is how registration proves an address whether or not the deployment
// offers it as a way to sign in.
if (await rejectIfUserLocked({ userId: user.id, req, res })) {
return;
}

if (user.verified && (await isPasskeyRequiredForUser(user.id))) {
await AuthEventService.log({
userId: user.id,
type: 'login_failed',
req,
metadata: { reason: 'Passkey required', method: 'email_otp' },
});
return res.status(403).json({ error: 'login_method_disabled' });
const wasVerified = Boolean(user.verified);

if (wasVerified && (await rejectDisabledLoginMethod('email_otp', req, res))) {
return;
}

logger.info('Verifying email');
Expand Down Expand Up @@ -402,7 +406,7 @@ export const verifyEmail = async (req: Request, res: Response) => {

await issueSessionAndRespond({
method: 'email_otp',
accountVerification: true,
accountVerification: !wasVerified,
user: {
id: user.id,
email: user.email,
Expand Down
36 changes: 36 additions & 0 deletions src/generated/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9864,6 +9864,24 @@ export interface paths {
};
};
};
/** @description HTTP 403 */
403: {
headers: {
[name: string]: unknown;
};
content: {
/**
* @example {
* "message": "string",
* "error": "string"
* }
*/
'application/json': {
message?: string;
error: string;
};
};
};
/** @description HTTP 429 */
429: {
headers: {
Expand Down Expand Up @@ -9976,6 +9994,24 @@ export interface paths {
};
};
};
/** @description HTTP 403 */
403: {
headers: {
[name: string]: unknown;
};
content: {
/**
* @example {
* "message": "string",
* "error": "string"
* }
*/
'application/json': {
message?: string;
error: string;
};
};
};
/** @description HTTP 429 */
429: {
headers: {
Expand Down
43 changes: 41 additions & 2 deletions src/middleware/rateLimit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,19 @@ function getOtpIdentityKey(req: Request) {
return `ip:${clientAddressKey(req)}`;
}

// Scopes an identifier's key to the attempt carrying the request, so one attempt cannot
// spend another's allowance. The attempt is the ephemeral token's jti, which bearer auth
// has already set by the time route middleware runs. A request without one falls back to
// the requester's address.
function withAttempt(identityKey: (req: Request) => string) {
return (req: Request) => {
const { attemptId } = req as AuthenticatedRequest;
const scope = attemptId ? `attempt:${attemptId}` : `ip:${clientAddressKey(req)}`;

return `${identityKey(req)}:${scope}`;
};
}

function getOAuthFlowKey(req: Request) {
return [req.params?.providerId ?? 'unknown-provider', clientAddressKey(req)].join(':');
}
Expand Down Expand Up @@ -119,13 +132,39 @@ function createFlowLimiter(
};
}

/**
* `perIdentity` is enforced per attempt. Across every attempt for one identifier the cap
* is this many times `perIdentity`, so minting fresh attempts still cannot message an
* address without bound, while a stranger has to drive several attempts' worth of
* requests before the owner is refused. A multiple rather than its own config value,
* because `flow_rate_limits` is a shared schema and a new key there needs a types release.
*/
export const IDENTIFIER_ATTEMPT_MULTIPLIER = 4;

function createIdentityLimiter(
pick: (limits: FlowRateLimits) => number,
identityKey: (req: Request) => string,
) {
const perAttempt = createFlowLimiter(pick, withAttempt(identityKey));
const perIdentifier = createFlowLimiter(
(limits) => pick(limits) * IDENTIFIER_ATTEMPT_MULTIPLIER,
identityKey,
);

return function identityLimiter(req: Request, res: Response, next: NextFunction) {
return perAttempt(req, res, (error?: unknown) =>
error ? next(error) : perIdentifier(req, res, next),
);
};
}

const magicLinkIpCachedLimiter = createFlowLimiter((limits) => limits.magicLink.perIp);
const magicLinkIdentityCachedLimiter = createFlowLimiter(
const magicLinkIdentityCachedLimiter = createIdentityLimiter(
(limits) => limits.magicLink.perIdentity,
getMagicLinkIdentityKey,
);
const otpIpCachedLimiter = createFlowLimiter((limits) => limits.otp.perIp);
const otpIdentityCachedLimiter = createFlowLimiter(
const otpIdentityCachedLimiter = createIdentityLimiter(
(limits) => limits.otp.perIdentity,
getOtpIdentityKey,
);
Expand Down
Loading
Loading