@nxgt/janus 0.12.0 → 0.14.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 (73) hide show
  1. package/README.md +62 -13
  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 +23 -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-sign-in.d.ts +5 -0
  8. package/dist/auth/config/resolve-sign-in.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/derived-keys.d.ts +9 -0
  12. package/dist/auth/derived-keys.d.ts.map +1 -0
  13. package/dist/auth/email-flows/reset-password.d.ts.map +1 -1
  14. package/dist/auth/events.d.ts +19 -4
  15. package/dist/auth/events.d.ts.map +1 -1
  16. package/dist/auth/index.d.ts +1 -1
  17. package/dist/auth/index.d.ts.map +1 -1
  18. package/dist/auth/one-time/index.d.ts +3 -1
  19. package/dist/auth/one-time/index.d.ts.map +1 -1
  20. package/dist/auth/one-time/window.d.ts +50 -0
  21. package/dist/auth/one-time/window.d.ts.map +1 -0
  22. package/dist/auth/password-written.d.ts +28 -5
  23. package/dist/auth/password-written.d.ts.map +1 -1
  24. package/dist/auth/port/types/tokens.d.ts +3 -2
  25. package/dist/auth/port/types/tokens.d.ts.map +1 -1
  26. package/dist/auth/second-factor/app-code-attempts.d.ts +5 -8
  27. package/dist/auth/second-factor/app-code-attempts.d.ts.map +1 -1
  28. package/dist/auth/second-factor/challenge.d.ts.map +1 -1
  29. package/dist/auth/second-factor/recovery-codes.d.ts.map +1 -1
  30. package/dist/auth/second-factor/recovery.d.ts.map +1 -1
  31. package/dist/auth/sign-in-attempts.d.ts +43 -0
  32. package/dist/auth/sign-in-attempts.d.ts.map +1 -0
  33. package/dist/auth/types/email-flows.d.ts +6 -3
  34. package/dist/auth/types/email-flows.d.ts.map +1 -1
  35. package/dist/auth/types/password.d.ts +8 -0
  36. package/dist/auth/types/password.d.ts.map +1 -1
  37. package/dist/auth/users/fields-patch.d.ts +8 -2
  38. package/dist/auth/users/fields-patch.d.ts.map +1 -1
  39. package/dist/auth/users/record-flows.d.ts.map +1 -1
  40. package/dist/auth/users/sign-in.d.ts +2 -2
  41. package/dist/auth/users/sign-in.d.ts.map +1 -1
  42. package/dist/auth/users/update.d.ts +12 -0
  43. package/dist/auth/users/update.d.ts.map +1 -0
  44. package/dist/chunks/{index-km7ffctx.js → index-6c9fbtv2.js} +2 -2
  45. package/dist/chunks/{index-rd2rddvv.js → index-baazjyvc.js} +2 -2
  46. package/dist/chunks/{index-j2gd8fry.js → index-hnwggyxs.js} +3 -2
  47. package/dist/chunks/{index-j2gd8fry.js.map → index-hnwggyxs.js.map} +3 -3
  48. package/dist/chunks/{index-jp2kka6d.js → index-kj3st57z.js} +2 -2
  49. package/dist/conformance/index.js +3 -3
  50. package/dist/errors/janus-error/base.d.ts +1 -0
  51. package/dist/errors/janus-error/base.d.ts.map +1 -1
  52. package/dist/errors/janus-error/codes.d.ts +3 -1
  53. package/dist/errors/janus-error/codes.d.ts.map +1 -1
  54. package/dist/errors/janus-error/options.d.ts +9 -2
  55. package/dist/errors/janus-error/options.d.ts.map +1 -1
  56. package/dist/index.js +234 -89
  57. package/dist/index.js.map +21 -16
  58. package/dist/permissions/index.js +3 -3
  59. package/docs/README.md +2 -2
  60. package/docs/guide/adapters.md +3 -2
  61. package/docs/guide/email-flows.md +40 -1
  62. package/docs/guide/errors.md +10 -2
  63. package/docs/guide/events.md +74 -13
  64. package/docs/guide/passwords.md +123 -3
  65. package/docs/guide/second-factor.md +4 -2
  66. package/docs/guide/users.md +16 -7
  67. package/docs/guide/vocabulary.md +2 -2
  68. package/docs/roadmap.md +34 -26
  69. package/docs/troubleshooting.md +66 -3
  70. package/package.json +1 -1
  71. /package/dist/chunks/{index-km7ffctx.js.map → index-6c9fbtv2.js.map} +0 -0
  72. /package/dist/chunks/{index-rd2rddvv.js.map → index-baazjyvc.js.map} +0 -0
  73. /package/dist/chunks/{index-jp2kka6d.js.map → index-kj3st57z.js.map} +0 -0
@@ -40,6 +40,7 @@ How the messages are shaped:
40
40
  - [`janus: secondFactor.keys: the key "<id>" is not 32 bytes in base64 …`](#janus-secondfactorkeys-the-key-id-is-not-32-bytes-in-base64--make-one-with-openssl-rand--base64-32)
41
41
  - [`"hasSecondFactor" is a field janus sets itself; rename it`](#hassecondfactor-is-a-field-janus-sets-itself-rename-it)
42
42
  - [`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)
43
+ - [`janus: signIn.throttle …` wiring messages](#janus-signinthrottle-wiring-messages)
43
44
  - [Other `janus:` wiring messages](#other-janus-wiring-messages)
44
45
 
45
46
  **Users, sessions and tokens**
@@ -51,6 +52,8 @@ How the messages are shaped:
51
52
  - [`USER_INVALID` — `<call>: the fields do not match the <type> schema …`](#user_invalid--call-the-fields-do-not-match-the-type-schema-n-issues-at-paths)
52
53
  - [`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)
53
54
  - [`CREDENTIALS_INVALID` — `<call>: the login and the password do not match`](#credentials_invalid--call-the-login-and-the-password-do-not-match)
55
+ - [`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)
56
+ - [`STORE_FAILED` — `<call>: the store dropped the attempts it had just stored`](#store_failed--call-the-store-dropped-the-attempts-it-had-just-stored)
54
57
  - [`HASH_UNSUPPORTED` — `<call>: no wired verifier claims the prefix "<prefix>"`](#hash_unsupported--call-no-wired-verifier-claims-the-prefix-prefix)
55
58
  - [`scryptHasher: the stored hash has the $scrypt$ prefix and not its format`](#scrypthasher-the-stored-hash-has-the-scrypt-prefix-and-not-its-format)
56
59
  - [`USER_INACTIVE` — `<call>: the user is inactive`](#user_inactive--call-the-user-is-inactive)
@@ -328,7 +331,7 @@ Also `janus: "<name>" cannot name a user type — janus() answers a method of th
328
331
 
329
332
  ### `janus: session.lifespan: "<value>" is not a duration; write a number followed by ms, s, m, h or d — for example "15m" or "720h"`
330
333
 
331
- The same for `session.renewAfter`, `tokens.verifyEmail`, `tokens.resetPassword`, `tokens.signInCode`, `tokens.stepUp` and `secondFactor.challenge` — and, at call time rather than in `janus()`, `assertFresh: maxAge: "<value>" is not a duration; …`, from a `maxAge` written wrong. Also `<option>: a duration must be above zero` and `<option>: a duration in milliseconds must be a finite number above zero`.
334
+ The same for `session.renewAfter`, `tokens.verifyEmail`, `tokens.resetPassword`, `tokens.signInCode`, `tokens.stepUp`, `secondFactor.challenge` and `signIn.throttle.window` — and, at call time rather than in `janus()`, `assertFresh: maxAge: "<value>" is not a duration; …`, from a `maxAge` written wrong. Also `<option>: a duration must be above zero` and `<option>: a duration in milliseconds must be a finite number above zero`.
332
335
 
333
336
  **When:** `janus({...})`.
334
337
  **Why:** a duration is a number of milliseconds, or a number followed by one unit. `'30 m'` compiles — TypeScript's `${number}` accepts the space — and is refused here.
@@ -403,6 +406,15 @@ janus({
403
406
  });
404
407
  ```
405
408
 
409
+ ### `janus: signIn.throttle` wiring messages
410
+
411
+ `TypeError`, at `janus()`: `janus: signIn must be an object — { throttle }`,
412
+ `janus: signIn.throttle must be { attempts, window }, or false to count
413
+ nothing`, `janus: signIn.throttle.attempts must be a whole number above
414
+ zero`, and a `janus: signIn.throttle.window` that is not a duration.
415
+ **Fix:** `signIn: { throttle: { attempts: 10, window: '15m' } }` — the
416
+ defaults — or `signIn: { throttle: false }` to count nothing.
417
+
406
418
  ### Other `janus:` wiring messages
407
419
 
408
420
  | Message | Fix |
@@ -423,6 +435,8 @@ janus({
423
435
  **Why:** a store that cannot answer throws; it never answers `null`. The driver's own error is on `error.cause` — not in the message, because a driver message can hold a connection string, and a connection string holds a password.
424
436
  **Fix:** answer **503**, and log `cause`. Never map it to 401, 404, `null` or `false`: that turns an outage into a silent lockout, where every user is told they do not exist.
425
437
 
438
+ **After a password write** — `resetPassword.confirm`, `changePassword`, `setPassword` — the failure may come from spending the user's reset links and second-factor challenges, which runs after the password is written: the new password is then in place. A retried `changePassword` with the old `current` answers `CREDENTIALS_INVALID`; the next `resetPassword.request` spends any link left live.
439
+
426
440
  ```ts
427
441
  import { JanusError } from '@nxgt/janus';
428
442
 
@@ -530,7 +544,7 @@ if (error instanceof UserInvalidError) {
530
544
  Also `changePassword: the current password does not match`.
531
545
 
532
546
  **When:** `signIn`, `changePassword`.
533
- **Why:** no user holds the login, the user has no password, or the password is wrong — **one code for the three**. Also a sign-in that verified a password written over while it ran (`reason: 'wrongPassword'`): its session is revoked, or its challenge spent, before the refusal. `error.reason` (`unknownLogin`, `noPassword`, `wrongPassword`) tells them apart for your logs and your rate limiter. A login holding a NUL character or a lone surrogate is `unknownLogin`: no user can hold one.
547
+ **Why:** no user holds the login, the user has no password, or the password is wrong — **one code for the three**. Also a sign-in that verified a password written over while it ran (`reason: 'wrongPassword'`): its session is revoked, or its challenge spent, before the refusal. `error.reason` (`unknownLogin`, `noPassword`, `wrongPassword`) tells them apart for your logs and your rate limiter; a login past its attempts has its own message, below. A login holding a NUL character or a lone surrogate is `unknownLogin`: no user can hold one.
534
548
  **Fix:** answer 401 with the same body whatever the reason:
535
549
 
536
550
  ```ts
@@ -538,6 +552,47 @@ Also `changePassword: the current password does not match`.
538
552
  return new Response('Wrong e-mail or password', { status: 401 });
539
553
  ```
540
554
 
555
+ ### `CREDENTIALS_INVALID` — `<call>: too many passwords tried at this login — wait for the next window`
556
+
557
+ `CredentialError`, with `reason: 'throttled'` and `retryAfter`.
558
+
559
+ **When:** `signIn`, after ten passwords were tried at this login in the
560
+ current 15-minute window — whatever this password is, **the right one
561
+ included**, and whether or not anybody holds the login. Nothing is compared.
562
+ **Why:** `signIn` throttles password guessing per login, on by default:
563
+ counted by the store, in fixed windows of the clock, shared by every process
564
+ over the same store. Nothing locks: the next window signs in, and a sign-in
565
+ that succeeds starts the count again. In tests with `fixedClock`, the window
566
+ never ends: advance the clock past `retryAfter`, or wire
567
+ `signIn: { throttle: false }`.
568
+ **Fix:** tell the visitor to wait `retryAfter` seconds — not that the
569
+ password is wrong — and answer 401, as `@nxgt/janus-hono`'s `janusErrors()`
570
+ does, with a `Retry-After` header. If nobody you know tried ten passwords,
571
+ somebody is guessing: look for `janus.signIn.throttled` in your logs. To
572
+ change the limit or turn it off:
573
+
574
+ ```ts
575
+ if (error instanceof JanusError && error.retryAfter !== undefined) {
576
+ const headers = { 'retry-after': String(error.retryAfter) };
577
+ return Response.json({ code: error.code, retryAfter: error.retryAfter }, { status: 401, headers });
578
+ }
579
+
580
+ janus({ ..., signIn: { throttle: { attempts: 20, window: '15m' } } });
581
+ ```
582
+
583
+ ### `STORE_FAILED` — `<call>: the store dropped the attempts it had just stored`
584
+
585
+ `StoreFailure`, with `slot: 'tokens'` and `operation: 'countAttempt'`.
586
+
587
+ **When:** `signIn`, when the tokens store answers `countAttempt` with `null`
588
+ for the count it stored an instant before.
589
+ **Why:** the store lost it — a Redis evicting keys under memory pressure, a
590
+ flush between the two calls, or an adapter whose `countAttempt` does not
591
+ find what `insertToken` wrote. The throttle cannot count, so the sign-in
592
+ fails rather than pass uncounted.
593
+ **Fix:** answer 503. Run the conformance suite against the adapter, and give
594
+ a Redis tokens store `maxmemory-policy noeviction`.
595
+
541
596
  ### `HASH_UNSUPPORTED` — `<call>: no wired verifier claims the prefix "<prefix>"`
542
597
 
543
598
  **When:** `signIn` or `changePassword`, for a user whose stored hash was written by another system — typically after an import.
@@ -566,7 +621,15 @@ janus({ ..., hasher: scryptHasher(), verifiers: [bcryptVerifier] }); // a Passwo
566
621
 
567
622
  **When:** `verifyEmail.confirm`, `resetPassword.confirm`.
568
623
  **Why:** a token is spent by its first use, and an expired one is spent too. `TOKEN_UNKNOWN` also covers a token whose user was deleted. The defaults are 24 h for `verifyEmail`, 1 h for `resetPassword`.
569
- **Fix:** answer 400 and offer to send a new link. To change the lifetimes:
624
+ A reset link answers `resetPassword.confirm: the token was already used` in
625
+ two more cases: a newer `resetPassword.request` for the same user spent it —
626
+ only the last link sent works, so a visitor who asked twice and clicked the
627
+ first e-mail gets `TOKEN_SPENT` — or the password was written since it was
628
+ sent, by another link's `confirm`, `changePassword` or `setPassword`. When two
629
+ requests race, even the last link can be spent: at most one survives,
630
+ sometimes none.
631
+ **Fix:** answer 400 and offer to send a new link — and tell the visitor to use
632
+ the latest e-mail. To change the lifetimes:
570
633
 
571
634
  ```ts
572
635
  janus({ ..., tokens: { verifyEmail: '72h', resetPassword: '2h' } });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxgt/janus",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "description": "Embeddable, type-safe identities and permissions: bring your own database",
5
5
  "license": "MIT",
6
6
  "type": "module",