@nxgt/janus 0.17.2 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +72 -18
  2. package/dist/auth/config/index.d.ts +1 -1
  3. package/dist/auth/config/index.d.ts.map +1 -1
  4. package/dist/auth/config/janus-config.d.ts +31 -0
  5. package/dist/auth/config/janus-config.d.ts.map +1 -1
  6. package/dist/auth/config/resolve-config.d.ts.map +1 -1
  7. package/dist/auth/config/resolve-throttle.d.ts +30 -0
  8. package/dist/auth/config/resolve-throttle.d.ts.map +1 -0
  9. package/dist/auth/config/resolved-config.d.ts +5 -0
  10. package/dist/auth/config/resolved-config.d.ts.map +1 -1
  11. package/dist/auth/config/warn-throttle-window.d.ts +14 -0
  12. package/dist/auth/config/warn-throttle-window.d.ts.map +1 -0
  13. package/dist/auth/counter-key.d.ts +19 -0
  14. package/dist/auth/counter-key.d.ts.map +1 -0
  15. package/dist/auth/email-flows/reset-password.d.ts.map +1 -1
  16. package/dist/auth/email-flows/verify-email.d.ts.map +1 -1
  17. package/dist/auth/index.d.ts +1 -1
  18. package/dist/auth/index.d.ts.map +1 -1
  19. package/dist/auth/janus.d.ts.map +1 -1
  20. package/dist/auth/magic-link.d.ts.map +1 -1
  21. package/dist/auth/mail-requests.d.ts +51 -0
  22. package/dist/auth/mail-requests.d.ts.map +1 -0
  23. package/dist/auth/sign-in-attempts.d.ts +3 -6
  24. package/dist/auth/sign-in-attempts.d.ts.map +1 -1
  25. package/dist/auth/sign-in-code.d.ts +3 -2
  26. package/dist/auth/sign-in-code.d.ts.map +1 -1
  27. package/dist/auth/step-up/request.d.ts.map +1 -1
  28. package/dist/auth/types/email-flows.d.ts +16 -6
  29. package/dist/auth/types/email-flows.d.ts.map +1 -1
  30. package/dist/auth/types/magic-link.d.ts +4 -2
  31. package/dist/auth/types/magic-link.d.ts.map +1 -1
  32. package/dist/auth/types/step-up.d.ts +4 -1
  33. package/dist/auth/types/step-up.d.ts.map +1 -1
  34. package/dist/chunks/{index-6fxq0kzf.js → index-4khsm7mw.js} +2 -2
  35. package/dist/chunks/{index-k136102s.js → index-cntabs9d.js} +2 -2
  36. package/dist/chunks/{index-ztng6018.js → index-kppkcbn5.js} +10 -3
  37. package/dist/chunks/{index-ztng6018.js.map → index-kppkcbn5.js.map} +4 -4
  38. package/dist/chunks/{index-7xe5x3eg.js → index-kvjj5xkp.js} +2 -2
  39. package/dist/conformance/index.js +3 -3
  40. package/dist/errors/janus-error/codes.d.ts +10 -0
  41. package/dist/errors/janus-error/codes.d.ts.map +1 -1
  42. package/dist/errors/janus-error/index.d.ts +1 -1
  43. package/dist/errors/janus-error/index.d.ts.map +1 -1
  44. package/dist/errors/janus-error/options.d.ts +4 -3
  45. package/dist/errors/janus-error/options.d.ts.map +1 -1
  46. package/dist/errors/janus-error/refusals.d.ts +8 -0
  47. package/dist/errors/janus-error/refusals.d.ts.map +1 -1
  48. package/dist/errors/janus-error/status.d.ts +1 -1
  49. package/dist/errors/janus-error/status.d.ts.map +1 -1
  50. package/dist/index.d.ts +1 -1
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +114 -25
  53. package/dist/index.js.map +15 -12
  54. package/dist/permissions/index.js +3 -3
  55. package/docs/README.md +1 -1
  56. package/docs/guide/email-flows.md +164 -4
  57. package/docs/guide/errors.md +12 -3
  58. package/docs/guide/magic-link.md +41 -5
  59. package/docs/guide/passwords.md +2 -1
  60. package/docs/guide/sign-in-code.md +46 -7
  61. package/docs/guide/step-up.md +38 -7
  62. package/docs/guide/users.md +1 -0
  63. package/docs/roadmap.md +14 -1
  64. package/docs/troubleshooting.md +88 -3
  65. package/package.json +1 -1
  66. package/dist/auth/config/resolve-sign-in.d.ts +0 -5
  67. package/dist/auth/config/resolve-sign-in.d.ts.map +0 -1
  68. /package/dist/chunks/{index-6fxq0kzf.js.map → index-4khsm7mw.js.map} +0 -0
  69. /package/dist/chunks/{index-k136102s.js.map → index-cntabs9d.js.map} +0 -0
  70. /package/dist/chunks/{index-7xe5x3eg.js.map → index-kvjj5xkp.js.map} +0 -0
@@ -4,15 +4,15 @@ import {
4
4
  UNSTORABLE,
5
5
  MAX_PAGE_SIZE2,
6
6
  pageLimit2
7
- } from "../chunks/index-6fxq0kzf.js";
7
+ } from "../chunks/index-4khsm7mw.js";
8
8
  import {
9
9
  PermissionDepthError2,
10
10
  isSetOf2,
11
11
  formatEntity2
12
- } from "../chunks/index-ztng6018.js";
12
+ } from "../chunks/index-kppkcbn5.js";
13
13
  import {
14
14
  createMemoryRelations2
15
- } from "../chunks/index-k136102s.js";
15
+ } from "../chunks/index-cntabs9d.js";
16
16
 
17
17
  // src/permissions/resolve/resolved.ts
18
18
  function admits(holders, subject) {
package/docs/README.md CHANGED
@@ -13,7 +13,7 @@ below are defined once, in [Words](guide/vocabulary.md#words).
13
13
  | --- | --- |
14
14
  | [Users](guide/users.md) | You are wiring `janus()`, declaring one or several user types, or calling `create`, `update`, `list`, `delete` |
15
15
  | [Sessions](guide/sessions.md) | You need to know who a request belongs to, set or clear the cookie, renew, sign out, or test expiry |
16
- | [E-mail verification and password reset](guide/email-flows.md) | You are sending a verification or reset link, and handling what comes back |
16
+ | [E-mail verification and password reset](guide/email-flows.md) | You are sending a verification or reset link, and handling what comes back — and how requests that send e-mail are throttled per address or user |
17
17
  | [Signing in with an e-mailed link](guide/magic-link.md) | You are signing users in with a link sent by e-mail — a magic link: the page that confirms it from a `POST` so mail scanners spend nothing, the lifetime, the errors, and what ends a link |
18
18
  | [Signing in with an e-mailed code](guide/sign-in-code.md) | You are signing users in with a six-digit code sent by e-mail — with no password, or beside one: requesting it without telling who exists, keeping the challenge, the attempts and the errors |
19
19
  | [The second factor](guide/second-factor.md) | You are turning on TOTP codes: making and rotating the sealing keys, the QR code, `signIn`'s `status`, confirming a challenge, recovery codes for a lost phone, disabling |
@@ -114,7 +114,11 @@ readonly verifyEmail: {
114
114
  };
115
115
  ```
116
116
 
117
- `send` issues a token for the user's **current** e-mail. `confirm` redeems it
117
+ `send` issues a token for the user's **current** e-mail. It is throttled
118
+ **per user**, five per 10-minute window: past it, `MailThrottledError`
119
+ (`MAIL_THROTTLED`, with `userId`) and no token. The application calls it for a
120
+ user it knows, so an unauthenticated loop on the address cannot spend the
121
+ count, and a user who changes e-mail buys no extra sends. `confirm` redeems it
118
122
  and sets `emailVerified`. A token sent to an e-mail the user has since changed
119
123
  is `TOKEN_STALE`: confirming it would verify an address nobody holds any more.
120
124
  The address is checked again on the very record the write replaces, so an
@@ -165,9 +169,15 @@ if (first !== null && second !== null) {
165
169
 
166
170
  Requests that arrive at once cannot each keep a link: each spends the
167
171
  others' once it issued its own, so at most one survives — sometimes none, and
168
- the visitor asks again. **Rate-limit `request` per address**, as for sign-in
169
- codes: each one sends an e-mail and cancels the link before it, so without a
170
- limit anyone who knows an address can keep its owner from ever using a link.
172
+ the visitor asks again. **`request` is throttled per address**, as for
173
+ sign-in codes: each one sends an e-mail and cancels the link before it, so
174
+ past five requests for one address in a 10-minute window it throws
175
+ `MailThrottledError` (`MAIL_THROTTLED`) and issues nothing; the link last sent
176
+ still works. An address nobody holds is counted and refused alike, and under
177
+ the limit still answers `null`. Somebody who knows an address can fill its
178
+ inbox with at most five reset e-mails a window, but cannot shut the reset:
179
+ the last one sent still works. Limit the route per client yourself too, and
180
+ see [the mail throttle](#requests-that-send-e-mail-are-throttled).
171
181
 
172
182
  **Writing a password spends every reset link still live.** A `confirm`,
173
183
  `changePassword` and `setPassword` each do, so a link sent before the
@@ -222,6 +232,156 @@ export async function resetPassword(request: Request): Promise<Response> {
222
232
  }
223
233
  ```
224
234
 
235
+ ## Requests that send e-mail are throttled
236
+
237
+ **Every request that hands out something to e-mail is counted**, on by default:
238
+ five per 10 minutes, per flow. Past the limit the request throws
239
+ `MailThrottledError` (`MAIL_THROTTLED`, 429) with `retryAfter` — the whole
240
+ seconds to the end of the window, at least 1 — and **a refused request spends,
241
+ invalidates and rotates nothing**: no token, code or challenge is minted, and
242
+ nothing earlier is spent, so **the last link or code sent still confirms until
243
+ it expires**, whoever asked for it. The count comes before anything is issued,
244
+ and the earlier tokens are spent only after a new one is issued.
245
+
246
+ ```ts
247
+ import { MailThrottledError } from '@nxgt/janus';
248
+
249
+ try {
250
+ await auth.resetPassword.request(email);
251
+ } catch (error) {
252
+ if (error instanceof MailThrottledError) {
253
+ // 429 and Retry-After: error.retryAfter — safe to show, it reveals nothing about accounts.
254
+ // Tell the visitor: "Check your inbox: the last e-mail we sent still works.
255
+ // You can ask for a new one in N minutes."
256
+ }
257
+ throw error;
258
+ }
259
+ ```
260
+
261
+ | Flow | Counted per | Why |
262
+ | --- | --- | --- |
263
+ | `magicLink.request(email)` | address | Anybody can call it with any address. Counted before the address is looked up, so an address nobody holds is refused like a registered one |
264
+ | `signInCode.request(email)` | address | The same |
265
+ | `resetPassword.request(email)` | address | The same |
266
+ | `verifyEmail.send(user)` | user | The application calls it for a user it knows: an unauthenticated loop on the address cannot spend this count, so it never blocks the user's own verification, and a user changing e-mail buys no extra sends |
267
+ | `stepUp.request(user)`, when it e-mails a code (`via: 'email'`) | user | The same. A step-up confirmed with the app (`via: 'secondFactor'`) sends nothing and is not counted |
268
+
269
+ **Keep the window no longer than the shortest token lifetime you set.** A
270
+ refused request spends nothing, but the last link or code sent still has to be
271
+ alive. With `tokens.signInCode: '5m'` and the 10-minute window, a code can lapse
272
+ while the visitor is still refused. `janus()` warns for it, once per call, with
273
+ `process.emitWarning` and the code `JANUS_THROTTLE_WINDOW`, naming each flow
274
+ whose lifetime is shorter than the window:
275
+
276
+ ```
277
+ janus: tokens.signInCode is 5m, shorter than mail.throttle.window, 10m — past the limit the last token sent can expire before the refusal ends. Set mail.throttle.window to 5m or less, or raise tokens.signInCode to 10m or more.
278
+ ```
279
+
280
+ Shorten `mail.throttle.window`, or raise the lifetime. Nothing is refused, so
281
+ a window you chose on purpose only costs the warning.
282
+
283
+ **Each flow counts on its own**: a loop on `magicLink.request` never shuts
284
+ `signInCode.request`. The address is normalised (trimmed, lowercased) and is
285
+ the same count whatever the user type.
286
+
287
+ | Option | Type | Default | Effect |
288
+ | --- | --- | --- | --- |
289
+ | `mail.throttle.attempts` | whole number above zero | `5` | Requests per flow, per address or user, per window |
290
+ | `mail.throttle.window` | `Duration` | `'10m'` | How long a window lasts |
291
+ | `mail.throttle` | `false` | | Counts nothing: limit those requests yourself |
292
+
293
+ ```ts
294
+ janus({ user, store, mail: { throttle: { attempts: 3, window: '1h' } } });
295
+ janus({ user, store, mail: { throttle: false } });
296
+ ```
297
+
298
+ ```ts
299
+ export interface MailConfig {
300
+ readonly throttle?: MailThrottleConfig | false;
301
+ }
302
+ export interface MailThrottleConfig {
303
+ readonly attempts?: number;
304
+ readonly window?: Duration;
305
+ }
306
+ ```
307
+
308
+ Five per ten minutes is no longer than the shortest default token lifetime
309
+ (`signInCode` and `stepUp` `'10m'`; `magicLink` `'15m'`, `resetPassword` `'1h'`,
310
+ `verifyEmail` `'24h'`), so under the defaults the last link or code sent —
311
+ issued inside the window — is still live for the whole refusal. It leaves room
312
+ for a visitor who asks again, and holds a loop to thirty e-mails an hour per
313
+ flow and address. A wrong value is refused when `janus()` is called, with
314
+ a `TypeError`: `janus: mail must be an object — { throttle }`, `janus:
315
+ mail.throttle must be { attempts, window }, or false to count nothing`,
316
+ `janus: mail.throttle.attempts must be a whole number above zero`, or a
317
+ `janus: mail.throttle.window` duration message.
318
+
319
+ **Fixed windows, and nothing locks.** The window is a fixed slice of the
320
+ clock, as the sign-in throttle's. Requests at once are counted in one store
321
+ write each: of twenty at once, exactly five are issued. The next window
322
+ answers again. A refused request is counted too but cannot extend the wait.
323
+
324
+ **Where the counts live.** In the tokens store, as `secondFactor` tokens named
325
+ by a keyed hash of the flow and the address or user id: no port change, no new
326
+ infrastructure, and never the address. That is one row per flow per address or
327
+ user per window. On PostgreSQL, `collectExpired()` does not collect lapsed
328
+ ones: schedule the delete, as for the sign-in throttle. A flushed or evicting
329
+ Redis forgets the counts, and a tokens store that cannot count throws
330
+ `STORE_FAILED` with nothing issued: it fails closed.
331
+
332
+ **What it does not do.**
333
+
334
+ - **It cannot lock somebody out.** Somebody who asks for an address in a loop
335
+ can fill its inbox with at most five e-mails per window per flow, but the
336
+ last e-mail they caused to be sent was a real e-mail to the real owner and
337
+ still works until it expires. The password sign-in is untouched.
338
+ - **A lifetime shorter than the window breaks that.** With
339
+ `tokens.signInCode: '5m'` and the default window, the last code can expire
340
+ before the window ends and the visitor has no live code until it does. Keep
341
+ `mail.throttle.window` no longer than the shortest of the `tokens.*`
342
+ lifetimes you use.
343
+ - **It is not per client.** `janus` never sees IP addresses, so one client
344
+ asking for many addresses is not counted. Add a per-IP ceiling in front of
345
+ the routes: `@nxgt/redis` (0.5.0 and later) has rate limits for it.
346
+
347
+ ```ts
348
+ import { bindRateLimit, defineRateLimit, GuardError } from '@nxgt/redis';
349
+
350
+ export const mailRequests = defineRateLimit({
351
+ name: 'mail-requests',
352
+ key: (p: { ip: string }) => p.ip,
353
+ limit: 20,
354
+ per: 3_600_000,
355
+ });
356
+ const perIp = bindRateLimit(client, mailRequests); // or redis.limits.<name> when wired
357
+
358
+ try {
359
+ await perIp.enforce({ ip }); // before auth.magicLink.request(email)
360
+ } catch (error) {
361
+ if (error instanceof GuardError && error.code === 'RATE_LIMITED') {
362
+ // error.retryAfter is in MILLISECONDS: Math.ceil(error.retryAfter / 1000) for Retry-After
363
+ }
364
+ throw error;
365
+ }
366
+ ```
367
+ - **A test suite** that requests more than five of one flow for one address or
368
+ user over a `fixedClock` is throttled: advance the clock past `retryAfter`,
369
+ or wire `mail: { throttle: false }`.
370
+
371
+ **Not throttled**, because they follow a write or a sign-in, which are bounded
372
+ elsewhere (the [sign-in throttle](passwords.md#password-guessing-is-throttled)
373
+ bounds sign-ins), not a request to mail an address:
374
+
375
+ - `update` changing the e-mail: an application write. Its notice to the former
376
+ address is sent from the `user.emailChanged` event.
377
+ - The notices sent from [events](events.md): a new device, a password changed,
378
+ a recovery code used, and the like.
379
+
380
+ `@nxgt/janus-hono` and `@nxgt/janus-graphql` answer `MAIL_THROTTLED` with 429,
381
+ `retryAfter` in the body (or the extensions) and a `Retry-After` header;
382
+ `@nxgt/janus-telemetry` writes a `janus.mail.throttled` warning with
383
+ `janus.mail.flow` and `janus.mail.retryAfter`, never the address.
384
+
225
385
  ## What a token refusal means
226
386
 
227
387
  | Code | When |
@@ -17,11 +17,11 @@ export function toResponse(error: unknown): Response {
17
17
 
18
18
  `statusOf(code)` is the table under [The codes](#the-codes), exported so every
19
19
  integration answers a code the same way — `@nxgt/janus-hono`'s `statusOf` is
20
- this one. It answers `JanusErrorStatus`, a union of the eight statuses it can
21
- give (`400 | 401 | 403 | 404 | 409 | 500 | 501 | 503`), so a framework whose
20
+ this one. It answers `JanusErrorStatus`, a union of the nine statuses it can
21
+ give (`400 | 401 | 403 | 404 | 409 | 429 | 500 | 501 | 503`), so a framework whose
22
22
  response takes a narrower type than `number` accepts it as it is.
23
23
 
24
- `JanusErrorCode` is a union of nineteen string literals, and `statusOf` is a
24
+ `JanusErrorCode` is a union of twenty-one string literals, and `statusOf` is a
25
25
  `switch` over it that is exhaustive: when a code is added, it stops compiling
26
26
  instead of answering `undefined` — and so does a `switch` of your own.
27
27
 
@@ -56,6 +56,7 @@ that wired the library, so no handler needs to tell it apart.
56
56
  | `PASSWORD_TOO_SHORT` | `CredentialError` | 400 | Below `password.minLength` | `minLength` — never the password |
57
57
  | `CREDENTIALS_INVALID` | `CredentialError` | 401 | Unknown login, no password, or the wrong one — **one code for the three** — and any password, the right one included, at a login past its attempts in the window — see [throttling](passwords.md#password-guessing-is-throttled) | `reason`, for your logs only; `retryAfter`, the seconds until the next window, when throttled |
58
58
  | `HASH_UNSUPPORTED` | `CredentialError` | 400 | A stored hash no wired hasher reads | `hashPrefix` — never the hash |
59
+ | `MAIL_THROTTLED` | `MailThrottledError` | 429 | A request that sends e-mail was made too often for one address or one user in the window — see [the mail throttle](email-flows.md#requests-that-send-e-mail-are-throttled). **Nothing was issued, spent or rotated**: the last link or code sent still works | `retryAfter`, the whole seconds to the end of the window, at least 1; `userType`; `userId` only for the per-user flows. No `reason` |
59
60
  | `USER_INACTIVE` | `UserInactiveError` | 403 | Deactivated; told only to someone who gave the right password, the right code, or a live sign-in link | `userId` |
60
61
  | `STEP_UP_REQUIRED` | `StepUpRequiredError` | 403 | `assertFresh`: the session proved who it is `maxAge` ago or more. Not a denial of the action — ask for a [step-up](step-up.md), then send the request again | `userId` |
61
62
  | `TOKEN_UNKNOWN`, `TOKEN_SPENT`, `TOKEN_EXPIRED`, `TOKEN_STALE` | `TokenError` | 400 | See [e-mail flows](email-flows.md#what-a-token-refusal-means). For a second factor's challenge: sign in again; for a sign-in code's: request a new code — see [sign-in codes](sign-in-code.md#what-confirm-refuses); for a sign-in link: ask for a new link — see [sign-in links](magic-link.md#what-confirm-refuses) | `userId` on `TOKEN_STALE` |
@@ -104,6 +105,14 @@ async function signIn(email: string, password: string): Promise<Response> {
104
105
  `Retry-After` header: the login is throttled, and the right password is
105
106
  refused too until then. It says nothing of whether the login exists — an
106
107
  unknown one is throttled alike.
108
+ - **`MAIL_THROTTLED`'s `retryAfter`** belongs in the body and a `Retry-After`
109
+ header with a 429. Unlike `reason`, it is **safe to show the visitor**: an
110
+ address nobody holds is counted and refused alike, so it reveals nothing
111
+ about accounts. Its message names the call and whether the address or the
112
+ user was counted: `magicLink.request: too many e-mails asked for this
113
+ address — the last one sent still works; use it, or wait for the next window`,
114
+ or `… for this user …`. Tell the visitor to use the last e-mail they received,
115
+ or to wait `retryAfter` seconds.
107
116
  - **`CODE_INVALID`'s `attemptsLeft`** belongs in the body — the form can say
108
117
  how many attempts are left. `0` means the challenge is spent: send the visitor
109
118
  back to the password.
@@ -90,9 +90,45 @@ once cannot each keep a link: at most one survives, sometimes none, and the
90
90
  visitor asks again. A link and a [sign-in code](sign-in-code.md) are
91
91
  separate kinds: asking for one leaves the other live.
92
92
 
93
- **Rate-limit the request route**, per address and per client, as you would a
94
- password reset: every call sends an e-mail, and cancels the link before it.
95
- Without a limit, anyone who knows an address can fill its inbox.
93
+ **`request` is throttled per address**: every call sends an e-mail and
94
+ cancels the link before it, so past five requests for one address in a
95
+ 10-minute window, `request` throws `MailThrottledError` (`MAIL_THROTTLED`)
96
+ and issues nothing: **a refused request spends and rotates nothing, so the
97
+ link last sent still works** until it expires, even when somebody else's
98
+ request caused it. The address is counted
99
+ before anything is looked up, so an address nobody holds is counted and
100
+ refused alike, and under the limit it still answers `null`. Sign-in codes and
101
+ password resets count on their own: a loop on this route never shuts them.
102
+ Answer it with a 429 and `Retry-After`:
103
+
104
+ ```ts
105
+ import { MailThrottledError } from '@nxgt/janus';
106
+
107
+ try {
108
+ const issued = await auth.magicLink.request(email);
109
+ // …send the e-mail from `issued`, whether or not there is one to send
110
+ } catch (error) {
111
+ if (error instanceof MailThrottledError) {
112
+ return Response.json(
113
+ {
114
+ error: 'too many e-mails — the last one we sent still works; use it, or wait',
115
+ retryAfter: error.retryAfter,
116
+ },
117
+ { status: 429, headers: { 'retry-after': String(error.retryAfter) } },
118
+ );
119
+ }
120
+ throw error;
121
+ }
122
+ ```
123
+
124
+ Tell the visitor to use the last e-mail they received, or to wait
125
+ `retryAfter` seconds: "Check your inbox: the last e-mail we sent still works.
126
+ You can ask for a new one in N minutes." The throttle is per address, not per
127
+ client: `janus` never sees IP addresses, so one client asking for many
128
+ addresses is not counted. Add a per-IP ceiling with `@nxgt/redis` rate limits
129
+ ([example](email-flows.md#requests-that-send-e-mail-are-throttled)). See
130
+ [the mail throttle](email-flows.md#requests-that-send-e-mail-are-throttled)
131
+ for the options.
96
132
 
97
133
  ## Building the link
98
134
 
@@ -332,8 +368,8 @@ has each message with its cause.
332
368
  for sign-in codes.
333
369
  - **Signing out everywhere.** It revokes sessions; a link opens a new one.
334
370
  - **The sign-in throttle.** `signIn.throttle` counts passwords tried at one
335
- login; a link is not a password, and its token cannot be guessed. Limit
336
- `magicLink.request` yourself, as above.
371
+ login; a link is not a password, and its token cannot be guessed. The
372
+ [mail throttle](#requesting-a-link) counts its requests instead.
337
373
 
338
374
  What does: its own redemption, a newer `request`, its fifteen minutes, an
339
375
  e-mail changed since (`TOKEN_STALE`), the user deactivated
@@ -234,7 +234,8 @@ The throttle counts per login. It does not see:
234
234
  - `changePassword`, which compares the current password too: limit it per
235
235
  user.
236
236
  - `resetPassword.request`, `signInCode.request` and `magicLink.request`,
237
- which send e-mail: limit them per address.
237
+ which send e-mail: [the mail throttle](email-flows.md#requests-that-send-e-mail-are-throttled)
238
+ counts them per address, but not per client. Limit them per client.
238
239
 
239
240
  ```ts
240
241
  if (!(await limiter.consume(`sign-in:${clientAddress}`))) {
@@ -130,9 +130,9 @@ patient observer; that limit is stated rather than denied.
130
130
 
131
131
  Every `request` issues a **new** code with its own challenge, and spends the
132
132
  earlier ones: only the last code sent works — see
133
- [attempts](#attempts). `janus` does not limit how often a code is asked for: **rate-limit the request route** per address
134
- and per client, as you would a password reset, or anyone can fill a user's
135
- inbox.
133
+ [attempts](#attempts). How often a code is asked for is limited per address by [the mail
134
+ throttle](email-flows.md#requests-that-send-e-mail-are-throttled); limit the
135
+ route per client yourself too.
136
136
 
137
137
  ## Sending the code by e-mail
138
138
 
@@ -252,10 +252,46 @@ Requests that arrive at once cannot each keep a code: each spends the others'
252
252
  once it issued its own, so at most one survives — sometimes none, and the
253
253
  visitor asks again. Guesses therefore never run against two live challenges.
254
254
 
255
- **Rate-limit `request` per address.** A new challenge takes only a new
256
- `request`, and each one sends an e-mail and cancels the code before it:
257
- without a limit, anyone who knows an address can fill its inbox, or keep
258
- its owner from ever typing a code in time.
255
+ **`request` is throttled per address.** A new challenge takes only a new
256
+ `request`, and each one sends an e-mail and cancels the code before it, so
257
+ past five requests for one address in a 10-minute window `request` throws
258
+ `MailThrottledError` (`MAIL_THROTTLED`) and issues nothing — no challenge is
259
+ minted, a refused request spends and rotates nothing, and the code last sent
260
+ still works until it expires, even when somebody else's request caused it. The address is counted before
261
+ anything is looked up, so an address nobody holds is counted and refused
262
+ alike; under the limit it still answers `null`. This flow counts on its own:
263
+ a loop on `magicLink.request` never shuts it. Answer it with a 429 and
264
+ `Retry-After`:
265
+
266
+ ```ts
267
+ import { MailThrottledError } from '@nxgt/janus';
268
+
269
+ try {
270
+ const issued = await auth.signInCode.request(email);
271
+ // …send the e-mail from `issued`, whether or not there is one to send
272
+ } catch (error) {
273
+ if (error instanceof MailThrottledError) {
274
+ return Response.json(
275
+ {
276
+ error: 'too many e-mails — the last one we sent still works; use it, or wait',
277
+ retryAfter: error.retryAfter,
278
+ },
279
+ { status: 429, headers: { 'retry-after': String(error.retryAfter) } },
280
+ );
281
+ }
282
+ throw error;
283
+ }
284
+ ```
285
+
286
+ With several user types the message is prefixed with the type, as in
287
+ `patient.signInCode.request: too many e-mails asked for this address — the last
288
+ one sent still works; use it, or wait for the next window`. Somebody who knows
289
+ an address cannot shut its e-mailed sign-in: the last code they caused to be
290
+ sent still works, and they can only fill the inbox, five per window. Tell the
291
+ visitor to use the last e-mail they received, or to wait `retryAfter` seconds.
292
+ `janus` never sees IP addresses, so one client asking for many addresses is
293
+ not counted: add a per-IP ceiling with `@nxgt/redis` rate limits
294
+ ([example](email-flows.md#requests-that-send-e-mail-are-throttled)).
259
295
 
260
296
  ### Lifetime
261
297
 
@@ -267,6 +303,9 @@ arrive and be read; a longer window is a longer life for a stolen challenge.
267
303
  janus({ ..., tokens: { signInCode: '15m' } });
268
304
  ```
269
305
 
306
+ Keep the mail window (`mail.throttle.window`, `'10m'` by default) no longer
307
+ than the lifetime: with `tokens.signInCode: '5m'` the last code can expire
308
+ before the window ends, and the visitor has none live until it does.
270
309
  ### The e-mail is verified
271
310
 
272
311
  A user whose `emailVerified` was `false` has it `true` once `confirm`
@@ -181,13 +181,44 @@ counted **per user**, five per 15-minute window, in the same count as
181
181
  challenge still gets five guesses per window, not five per challenge. The
182
182
  app's code accepted is spent, as at a sign-in: it confirms nothing else.
183
183
 
184
- **Rate-limit `request` per user.** An e-mailed code has no such window:
185
- five guesses per challenge, and a new challenge takes only a new `request`.
186
- A stolen session could ask again and again — each request also sends an
187
- e-mail and cancels the code before it. Limit `stepUp.request` per user —
188
- a few an hour is plenty for a person — as you limit `signInCode.request`
189
- per address; the `janus.stepUp.asked` event of `@nxgt/janus-telemetry` is
190
- the signal to alert on.
184
+ **`request` is throttled per user, when it e-mails.** An e-mailed code has no
185
+ such window: five guesses per challenge, and a new challenge takes only a new
186
+ `request`. A stolen session could ask again and again — each request also
187
+ sends an e-mail and cancels the code before it. So `stepUp.request` counts
188
+ the user, five per 10-minute window (`mail: { throttle }`), and past it throws
189
+ `MailThrottledError` (`MAIL_THROTTLED`, with `userId`) and issues nothing: a
190
+ refused request spends nothing, so the code last sent still works until it
191
+ expires. Tell the user to use the last e-mail they received, or to wait
192
+ `retryAfter` seconds.
193
+ It counts **only** `via: 'email'`: a step-up confirmed with the app
194
+ (`via: 'secondFactor'`) sends nothing and is not counted.
195
+
196
+ It is per user rather than per address because the application calls it for a
197
+ user it knows: nobody without their session can spend the count, so an
198
+ unauthenticated loop on their address never blocks their own step-up, and a
199
+ user who changes e-mail buys no extra sends. Answer it with a 429 and
200
+ `Retry-After`:
201
+
202
+ ```ts
203
+ import { MailThrottledError } from '@nxgt/janus';
204
+
205
+ try {
206
+ const issued = await auth.stepUp.request(current.user);
207
+ // …send the e-mail from `issued`, whether or not there is one to send
208
+ } catch (error) {
209
+ if (error instanceof MailThrottledError) {
210
+ return Response.json(
211
+ { error: 'too many e-mails', retryAfter: error.retryAfter },
212
+ { status: 429, headers: { 'retry-after': String(error.retryAfter) } },
213
+ );
214
+ }
215
+ throw error;
216
+ }
217
+ ```
218
+
219
+ The `janus.stepUp.asked` event of `@nxgt/janus-telemetry` is the signal to
220
+ alert on; it also writes a `janus.mail.throttled` warning when the throttle
221
+ refuses. See [the mail throttle](email-flows.md#requests-that-send-e-mail-are-throttled).
191
222
 
192
223
  A step-up and a sign-in code are **two kinds of token**: a sign-in code's
193
224
  challenge confirms no action, and a step-up's code signs nobody in.
@@ -98,6 +98,7 @@ that cuts an emoji in half, say.
98
98
  | `tokens.magicLink` | `Duration` | `'15m'` | How long an e-mailed sign-in link lives. See [sign-in links](magic-link.md) |
99
99
  | `tokens.stepUp` | `Duration` | `'10m'` | How long a step-up's challenge — and its e-mailed code — lives. See [step-up](step-up.md) |
100
100
  | `signIn.throttle` | `{ attempts?, window? }` or `false` | `{ attempts: 10, window: '15m' }` | Passwords one login may try per window, the right one included, before `signIn` answers `CREDENTIALS_INVALID` with `retryAfter` until the window ends. `false` counts nothing. See [passwords](passwords.md#password-guessing-is-throttled) |
101
+ | `mail.throttle` | `{ attempts?, window? }` or `false` | `{ attempts: 5, window: '10m' }` | E-mails one address (`magicLink`, `signInCode`, `resetPassword` requests) or one user (`verifyEmail.send`, an e-mailed `stepUp.request`) may ask for per flow and window, before the request throws `MAIL_THROTTLED` with `retryAfter` and issues nothing (the last link or code sent still works). Keep `window` no longer than the shortest `tokens.*` lifetime you use. `false` counts nothing. See [e-mail flows](email-flows.md#requests-that-send-e-mail-are-throttled) |
101
102
  | `secondFactor` | `{ issuer, keys, challenge? }` | none | A TOTP second factor for every type with a password. Changes what `signIn` answers — see [the second factor](second-factor.md#configuration) |
102
103
  | `devices` | `{ keys }` | none | The keys device tokens are signed with, to tell a sign-in from a new device. Without it, no sign-in may be given a `device` — see [devices](devices.md#wiring) |
103
104
  | `events` | `UserEventListener` | none | Called with every user event — `user.created`, `user.deleted`, … — after the write, awaited. See [user events](events.md) |
package/docs/roadmap.md CHANGED
@@ -16,7 +16,8 @@ Nothing yet.
16
16
  - **Collecting lapsed one-time tokens** — `collectExpired()` deletes lapsed
17
17
  sessions only. On a store with no TTL (PostgreSQL), lapsed tokens stay
18
18
  until you delete them, and the sign-in throttle adds one per login tried
19
- per window. A port method to collect them, as sessions are, would let the
19
+ per window, and the mail throttle one per flow per address or user per
20
+ window. A port method to collect them, as sessions are, would let the
20
21
  core do it.
21
22
 
22
23
  - **More official adapters** — the ports are cut where atomicity is not
@@ -70,6 +71,18 @@ Nothing yet.
70
71
  The last ten, newest first, each with the version it came in. Everything
71
72
  before is in the [CHANGELOG](../CHANGELOG.md).
72
73
 
74
+ - **Requests that send e-mail are throttled, v0.18.0** —
75
+ `janus({ mail: { throttle: { attempts, window } } })`, five per ten
76
+ minutes by default and `false` to turn it off. `magicLink.request`,
77
+ `signInCode.request` and `resetPassword.request` count per address;
78
+ `verifyEmail.send` and an e-mailed `stepUp.request` count per user; each
79
+ flow on its own. Past the limit a request throws `MailThrottledError`
80
+ (`MAIL_THROTTLED`, 429) with `retryAfter` and issues, spends and rotates
81
+ nothing: the last link or code sent still works, so a loop on an address
82
+ cannot lock its owner out.
83
+ The window is no longer than the shortest default token lifetime. The counts
84
+ live in the tokens store, with no port change. `MailConfig` and
85
+ `MailThrottleConfig` are exported.
73
86
  - **A sign-in from a new device, v0.17.0** — `janus({ devices: { keys } })`,
74
87
  and `{ device }` as the last argument of `signUp`, `signIn`,
75
88
  `secondFactor.confirm`, `secondFactor.recover`, `signInCode.confirm` and
@@ -42,6 +42,8 @@ How the messages are shaped:
42
42
  - [`janus: devices.keys: …`](#janus-deviceskeys-expected-at-least-one-key---id-key--the-first-seals)
43
43
  - [`janus: events must be a function that takes a user event …`](#janus-events-must-be-a-function-that-takes-a-user-event--webhooks---from-nxgtjanus-webhooks-or-your-own)
44
44
  - [`janus: signIn.throttle …` wiring messages](#janus-signinthrottle-wiring-messages)
45
+ - [`janus: mail.throttle …` wiring messages](#janus-mailthrottle-wiring-messages)
46
+ - [`janus: tokens.<flow> is 5m, shorter than mail.throttle.window, 10m …`](#janus-tokensflow-is-5m-shorter-than-mailthrottlewindow-10m-)
45
47
  - [Other `janus:` wiring messages](#other-janus-wiring-messages)
46
48
 
47
49
  **Users, sessions and tokens**
@@ -54,6 +56,7 @@ How the messages are shaped:
54
56
  - [`PASSWORD_TOO_SHORT` — `<call>: the password is shorter than the policy's <n> characters`](#password_too_short--call-the-password-is-shorter-than-the-policys-n-characters)
55
57
  - [`CREDENTIALS_INVALID` — `<call>: the login and the password do not match`](#credentials_invalid--call-the-login-and-the-password-do-not-match)
56
58
  - [`CREDENTIALS_INVALID` — `<call>: too many passwords tried at this login …`](#credentials_invalid--call-too-many-passwords-tried-at-this-login--wait-for-the-next-window)
59
+ - [`MAIL_THROTTLED` — `<call>: too many e-mails asked for this address …`, or `… for this user …`](#mail_throttled--call-too-many-e-mails-asked-for-this-address--the-last-one-sent-still-works-use-it-or-wait-for-the-next-window)
57
60
  - [`STORE_FAILED` — `<call>: the store dropped the attempts it had just stored`](#store_failed--call-the-store-dropped-the-attempts-it-had-just-stored)
58
61
  - [`HASH_UNSUPPORTED` — `<call>: no wired verifier claims the prefix "<prefix>"`](#hash_unsupported--call-no-wired-verifier-claims-the-prefix-prefix)
59
62
  - [`scryptHasher: the stored hash has the $scrypt$ prefix and not its format`](#scrypthasher-the-stored-hash-has-the-scrypt-prefix-and-not-its-format)
@@ -465,6 +468,39 @@ zero`, and a `janus: signIn.throttle.window` that is not a duration.
465
468
  **Fix:** `signIn: { throttle: { attempts: 10, window: '15m' } }` — the
466
469
  defaults — or `signIn: { throttle: false }` to count nothing.
467
470
 
471
+ ### `janus: mail.throttle` wiring messages
472
+
473
+ `TypeError`, at `janus()`: `janus: mail must be an object — { throttle }`,
474
+ `janus: mail.throttle must be { attempts, window }, or false to count
475
+ nothing`, `janus: mail.throttle.attempts must be a whole number above zero`,
476
+ and a `janus: mail.throttle.window` that is not a duration.
477
+ **Fix:** `mail: { throttle: { attempts: 5, window: '10m' } }` — the defaults —
478
+ or `mail: { throttle: false }` to count nothing.
479
+
480
+ ### `janus: tokens.<flow> is 5m, shorter than mail.throttle.window, 10m …`
481
+
482
+ A warning, not an error: `janus()` returns, and emits through
483
+ `process.emitWarning` with the code `JANUS_THROTTLE_WINDOW`, once per call. The
484
+ text, here for `tokens.signInCode: '5m'` with the default window:
485
+
486
+ ```
487
+ janus: tokens.signInCode is 5m, shorter than mail.throttle.window, 10m — past the limit the last token sent can expire before the refusal ends. Set mail.throttle.window to 5m or less, or raise tokens.signInCode to 10m or more.
488
+ ```
489
+
490
+ With several flows shorter than the window, one warning lists each
491
+ (`tokens.magicLink is 2m, … ; tokens.stepUp is 30s, …`). **Why:** a request the
492
+ mail throttle refuses spends nothing, so the last link or code sent still works,
493
+ but only until it expires; with a lifetime shorter than the window it can lapse
494
+ while the visitor is still refused. **Fix:** make the window no longer than the
495
+ shortest lifetime, or raise the lifetime:
496
+
497
+ ```ts
498
+ janus({ user, store, tokens: { signInCode: '5m' }, mail: { throttle: { window: '5m' } } });
499
+ ```
500
+
501
+ A runtime without `process.emitWarning` stays silent. Silence it with
502
+ `node --disable-warning=JANUS_THROTTLE_WINDOW`.
503
+
468
504
  ### Other `janus:` wiring messages
469
505
 
470
506
  | Message | Fix |
@@ -641,6 +677,52 @@ if (error instanceof JanusError && error.retryAfter !== undefined) {
641
677
  janus({ ..., signIn: { throttle: { attempts: 20, window: '15m' } } });
642
678
  ```
643
679
 
680
+ ### `MAIL_THROTTLED` — `<call>: too many e-mails asked for this address — the last one sent still works; use it, or wait for the next window`
681
+
682
+ `MailThrottledError`, with `retryAfter` and `userType`. The per-user flows say
683
+ `… too many e-mails asked for this user — the last one sent still works; use
684
+ it, or wait for the next window` and carry `userId`.
685
+
686
+ **When:** `magicLink.request`, `signInCode.request` or
687
+ `resetPassword.request` (per address), or `verifyEmail.send` and an e-mailed
688
+ `stepUp.request` (per user), past five requests of that flow in the current
689
+ 10-minute window — whether or not anybody holds the address. `<call>` is the
690
+ call, such as `magicLink.request` or `patient.signInCode.request`.
691
+ **Why:** requests that send e-mail are throttled, on by default, so a loop
692
+ cannot fill an inbox or cancel its owner's link or code. Each flow counts on
693
+ its own, in fixed windows of the clock, by the store. **A refused request
694
+ spends, invalidates and rotates nothing**: no token, code or challenge is
695
+ issued, and the last link or code sent — by anyone's request, an attacker's
696
+ included — still confirms until it expires, so a loop on a victim's address
697
+ cannot lock them out. Nothing locks: the next window answers again. In tests with `fixedClock`, the window never
698
+ ends.
699
+ **Fix:** answer 429 with a `Retry-After` header, and tell the visitor to use
700
+ the last e-mail they received (it still works), or to wait `retryAfter`
701
+ seconds: "Check your inbox: the last e-mail we sent still works. You can ask for
702
+ a new one in N minutes." It reveals nothing about accounts, so it is safe to
703
+ show.
704
+ In tests, advance the clock past `retryAfter`, or wire `mail: { throttle:
705
+ false }`. If nobody you know asked five times, somebody is looping on the
706
+ route: `janus` never sees IP addresses, so limit it per client, which the
707
+ throttle does not — with `@nxgt/redis` rate limits (`defineRateLimit`,
708
+ `bindRateLimit`; `enforce()` throws `GuardError` `RATE_LIMITED` whose
709
+ `retryAfter` is in milliseconds), as in the
710
+ [e-mail flows guide](guide/email-flows.md#requests-that-send-e-mail-are-throttled).
711
+ If you set a token lifetime shorter than the window (`tokens.signInCode: '5m'`
712
+ with the default), the last code can expire before the window ends: keep
713
+ `mail.throttle.window` no longer than the shortest `tokens.*` you use.
714
+
715
+ ```ts
716
+ import { MailThrottledError } from '@nxgt/janus';
717
+
718
+ if (error instanceof MailThrottledError) {
719
+ const headers = { 'retry-after': String(error.retryAfter) };
720
+ return Response.json({ code: error.code, retryAfter: error.retryAfter }, { status: 429, headers });
721
+ }
722
+
723
+ janus({ ..., mail: { throttle: { attempts: 10, window: '10m' } } });
724
+ ```
725
+
644
726
  ### `STORE_FAILED` — `<call>: the store dropped the attempts it had just stored`
645
727
 
646
728
  `StoreFailure`, with `slot: 'tokens'` and `operation: 'countAttempt'`.
@@ -1153,7 +1235,9 @@ A `TypeError`.
1153
1235
 
1154
1236
  The messages start with `signInCode.confirm` — prefixed by the type with
1155
1237
  several user types: `patient.signInCode.confirm: …`. `signInCode.request`
1156
- throws nothing but `STORE_FAILED`: an e-mail it cannot sign in is `null`.
1238
+ throws nothing but `STORE_FAILED` and
1239
+ [`MAIL_THROTTLED`](#mail_throttled--call-too-many-e-mails-asked-for-this-address--the-last-one-sent-still-works-use-it-or-wait-for-the-next-window):
1240
+ an e-mail it cannot sign in is `null`.
1157
1241
  `signInCode.confirm` also rejects with
1158
1242
  [`CODE_INVALID`](#code_invalid--call-the-code-does-not-match-or-was-already-used),
1159
1243
  [`TOKEN_UNKNOWN`, `TOKEN_SPENT`, `TOKEN_EXPIRED`](#token_unknown-token_spent-token_expired),
@@ -1236,8 +1320,9 @@ janus({
1236
1320
 
1237
1321
  `magicLink.confirm`'s messages start with `magicLink.confirm` — prefixed by
1238
1322
  the type with several user types: `patient.magicLink.confirm: …`.
1239
- `magicLink.request` throws nothing but `STORE_FAILED`: an e-mail it cannot
1240
- sign in is `null`. `magicLink.confirm` rejects with
1323
+ `magicLink.request` throws nothing but `STORE_FAILED` and
1324
+ [`MAIL_THROTTLED`](#mail_throttled--call-too-many-e-mails-asked-for-this-address--the-last-one-sent-still-works-use-it-or-wait-for-the-next-window):
1325
+ an e-mail it cannot sign in is `null`. `magicLink.confirm` rejects with
1241
1326
  [`TOKEN_UNKNOWN`, `TOKEN_SPENT`, `TOKEN_EXPIRED`](#token_unknown-token_spent-token_expired),
1242
1327
  [`TOKEN_STALE`](#token_stale--call-the-token-was-sent-to-an-e-mail-the-user-no-longer-has),
1243
1328
  [`USER_INACTIVE`](#user_inactive--call-the-user-is-inactive) and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxgt/janus",
3
- "version": "0.17.2",
3
+ "version": "0.18.0",
4
4
  "description": "Embeddable, type-safe identities and permissions: bring your own database",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,5 +0,0 @@
1
- /** Resolves `signIn.throttle`: on unless `false`, ten passwords per login per fifteen minutes. */
2
- import type { SignInConfig } from './janus-config';
3
- import type { ResolvedConfig } from './resolved-config';
4
- export declare function resolveSignInThrottle(config: SignInConfig | undefined, where: string): ResolvedConfig['signInThrottle'];
5
- //# sourceMappingURL=resolve-sign-in.d.ts.map