@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
@@ -4,15 +4,15 @@ import {
4
4
  UNSTORABLE,
5
5
  MAX_PAGE_SIZE2,
6
6
  pageLimit2
7
- } from "../chunks/index-jp2kka6d.js";
7
+ } from "../chunks/index-kj3st57z.js";
8
8
  import {
9
9
  PermissionDepthError2,
10
10
  isSetOf2,
11
11
  formatEntity2
12
- } from "../chunks/index-j2gd8fry.js";
12
+ } from "../chunks/index-hnwggyxs.js";
13
13
  import {
14
14
  createMemoryRelations2
15
- } from "../chunks/index-km7ffctx.js";
15
+ } from "../chunks/index-6c9fbtv2.js";
16
16
 
17
17
  // src/permissions/resolve/resolved.ts
18
18
  function admits(holders, subject) {
package/docs/README.md CHANGED
@@ -17,8 +17,8 @@ below are defined once, in [Words](guide/vocabulary.md#words).
17
17
  | [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 |
18
18
  | [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 |
19
19
  | [Step-up](guide/step-up.md) | A sensitive action — changing the e-mail, disabling the second factor, deleting the account — should ask a signed-in user to prove again who they are: `assertFresh`, `stepUp.request` and `confirm`, by e-mailed code or by their app |
20
- | [User events](guide/events.md) | You want to hear when a user is created, verifies their e-mail, resets their password, turns the second factor on or off, uses a recovery code or is deleted — to queue it, sync it, or send it as a webhook |
21
- | [Password hashing](guide/passwords.md) | You are choosing a hasher, raising its cost, moving to argon2id, or importing hashes from another system |
20
+ | [User events](guide/events.md) | You want to hear when a user is created, verifies their e-mail, resets or changes their password, changes their e-mail, turns the second factor on or off, uses a recovery code or is deleted — to queue it, sync it, or send it as a webhook |
21
+ | [Password hashing](guide/passwords.md) | You are choosing a hasher, raising its cost, moving to argon2id, or importing hashes from another system — or how `signIn` throttles password guessing per login, and what you still limit per client |
22
22
 
23
23
  ### Permissions — `@nxgt/janus/permissions`
24
24
 
@@ -282,8 +282,9 @@ driver's error in `StoreFailure` as in [the six rules](#the-six-rules):
282
282
  Spends every **unspent** token of one user and one `kind` at `at` — but the
283
283
  one whose hash is `except`, when given — and answers how many it spent. The
284
284
  core calls it right after issuing a sign-in code, with that code's hash as
285
- `except`, so only the last code sent works; and after writing a password,
286
- for the user's `secondFactor` challenges:
285
+ `except`, so only the last code sent works — and so after issuing a step-up
286
+ or a reset link; and after writing a password, for the user's
287
+ `resetPassword` links and `secondFactor` challenges:
287
288
 
288
289
  | The stored token | Written | Counted |
289
290
  | --- | --- | --- |
@@ -144,6 +144,45 @@ export async function forgotPassword(request: Request): Promise<Response> {
144
144
  }
145
145
  ```
146
146
 
147
+ **At most one reset link is live per user.** A `request` issues its link,
148
+ then spends every other reset link of the user, so the link in an earlier
149
+ e-mail answers `TOKEN_SPENT` — only the last one works, as for [sign-in
150
+ codes](sign-in-code.md):
151
+
152
+ ```ts
153
+ const first = await auth.resetPassword.request(email);
154
+ const second = await auth.resetPassword.request(email); // the visitor asked again
155
+ if (first !== null && second !== null) {
156
+ await auth.resetPassword.confirm(first.token, newPassword); // TOKEN_SPENT
157
+ await auth.resetPassword.confirm(second.token, newPassword); // reset
158
+ }
159
+ ```
160
+
161
+ Requests that arrive at once cannot each keep a link: each spends the
162
+ others' once it issued its own, so at most one survives — sometimes none, and
163
+ the visitor asks again. **Rate-limit `request` per address**, as for sign-in
164
+ codes: each one sends an e-mail and cancels the link before it, so without a
165
+ limit anyone who knows an address can keep its owner from ever using a link.
166
+
167
+ **Writing a password spends every reset link still live.** A `confirm`,
168
+ `changePassword` and `setPassword` each do, so a link sent before the
169
+ password changed cannot replace it again:
170
+
171
+ ```ts
172
+ const issued = await auth.resetPassword.request(email);
173
+ await auth.changePassword(user, { current, next });
174
+ if (issued !== null) {
175
+ await auth.resetPassword.confirm(issued.token, other); // TOKEN_SPENT
176
+ }
177
+ ```
178
+
179
+ The links are spent **after** the password is written, so a link issued
180
+ during the write is spent too. If the store fails at that step, the call
181
+ rejects with `STORE_FAILED` although the password is written — answer 503 as
182
+ usual — and the older links may still be live until the next `request`,
183
+ which spends them. Sign-in codes and step-ups are not spent: the password
184
+ proves neither.
185
+
147
186
  `confirm` sets the password, marks the e-mail verified — the link proved it —
148
187
  and **signs the user out everywhere**: their sessions are revoked, and every
149
188
  second-factor challenge still open is spent, so a sign-in started with the
@@ -183,7 +222,7 @@ export async function resetPassword(request: Request): Promise<Response> {
183
222
  | Code | When |
184
223
  | --- | --- |
185
224
  | `TOKEN_UNKNOWN` | No token holds that secret — or it was issued for the other flow: a verification token is not a reset token |
186
- | `TOKEN_SPENT` | Already redeemed. Every token is single use |
225
+ | `TOKEN_SPENT` | Already redeemed — every token is single use. A reset link is also spent by a newer `request` for the same user, and by any password written since it was sent |
187
226
  | `TOKEN_EXPIRED` | Its lifespan passed. It is spent all the same, so it cannot be retried |
188
227
  | `TOKEN_STALE` | Sent to an e-mail the user no longer has |
189
228
 
@@ -54,7 +54,7 @@ that wired the library, so no handler needs to tell it apart.
54
54
  | `VERSION_CONFLICT` | `StoreConflict` (`on: 'version'`) | 409 | `ifVersion` no longer matches; nothing was written. From `secondFactor.confirm` or `recover`, the same code used by two sign-ins at once: the other opened the session | `expectedVersion`, `actualVersion` |
55
55
  | `USER_INVALID` | `UserInvalidError` | 400 | The fields failed the schema | `issues`, field by field |
56
56
  | `PASSWORD_TOO_SHORT` | `CredentialError` | 400 | Below `password.minLength` | `minLength` — never the password |
57
- | `CREDENTIALS_INVALID` | `CredentialError` | 401 | Unknown login, no password, or the wrong one — **one code for the three** | `reason`, for your logs only |
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
59
  | `USER_INACTIVE` | `UserInactiveError` | 403 | Deactivated; told only to someone who gave the right password, or the right code | `userId` |
60
60
  | `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` |
@@ -81,7 +81,11 @@ async function signIn(email: string, password: string): Promise<Response> {
81
81
  return Response.json({ token });
82
82
  } catch (error) {
83
83
  if (error instanceof CredentialError && error.code === 'CREDENTIALS_INVALID') {
84
- console.warn('sign-in refused', error.reason); // 'unknownLogin' | 'noPassword' | 'wrongPassword'
84
+ console.warn('sign-in refused', error.reason); // 'unknownLogin' | 'noPassword' | 'wrongPassword' | 'throttled'
85
+ if (error.retryAfter !== undefined) {
86
+ const headers = { 'retry-after': String(error.retryAfter) };
87
+ return Response.json({ error: 'invalid', retryAfter: error.retryAfter }, { status: 401, headers });
88
+ }
85
89
  return Response.json({ error: 'invalid' }, { status: 401 });
86
90
  }
87
91
  if (error instanceof StoreFailure) return new Response(null, { status: 503 });
@@ -96,6 +100,10 @@ async function signIn(email: string, password: string): Promise<Response> {
96
100
  would read as "no".
97
101
  - **`VERSION_CONFLICT` is a retry**: read the user again, reapply, write with
98
102
  the new `version`.
103
+ - **`CREDENTIALS_INVALID`'s `retryAfter`** belongs in the body and a
104
+ `Retry-After` header: the login is throttled, and the right password is
105
+ refused too until then. It says nothing of whether the login exists — an
106
+ unknown one is throttled alike.
99
107
  - **`CODE_INVALID`'s `attemptsLeft`** belongs in the body — the form can say
100
108
  how many attempts are left. `0` means the challenge is spent: send the visitor
101
109
  back to the password.
@@ -1,8 +1,8 @@
1
1
  # User events
2
2
 
3
3
  This page is for hearing what happens to a user once it is written: created,
4
- e-mail verified, password reset, second factor turned on or off, recovery
5
- codes regenerated or one used, deleted. Another service can then follow
4
+ e-mail verified, password reset or changed, e-mail changed, second factor
5
+ turned on or off, recovery codes regenerated or one used, deleted. Another service can then follow
6
6
  without polling. `janus` hands each event to one function you give it;
7
7
  **delivering it is yours** — or
8
8
  [`@nxgt/janus-webhooks`](https://www.npmjs.com/package/@nxgt/janus-webhooks)'s:
@@ -38,13 +38,15 @@ received[0];
38
38
  The words — **user event**, **listener** — are defined in
39
39
  [the vocabulary](vocabulary.md#identities).
40
40
 
41
- ## The eight types
41
+ ## The ten types
42
42
 
43
43
  | `type` | Sent by | Not sent |
44
44
  | --- | --- | --- |
45
45
  | `user.created` | `create`, `signUp` — once the user is inserted, even if `signUp`'s session then fails to open | for a sign-up refused (`LOGIN_TAKEN`, `USER_INVALID`, `PASSWORD_TOO_SHORT`) |
46
46
  | `user.emailVerified` | `verifyEmail.confirm`; `resetPassword.confirm`, whose link proves the e-mail; `signInCode.confirm`, whose code does | for an e-mail already verified, or a refused confirm |
47
47
  | `user.passwordReset` | `resetPassword.confirm` | for `setPassword` or `changePassword` — they are not resets |
48
+ | `user.passwordChanged` | `changePassword` and `setPassword` — a first password set on a user created without one included — once the password is written — even if spending the reset links and second-factor challenges it ends then fails | for a reset, which is `user.passwordReset` alone; for a `changePassword` refused (`CREDENTIALS_INVALID`) |
49
+ | `user.emailChanged` | `update`, when it changed the e-mail — added, replaced or removed. It carries `formerEmail`, the address before | for an update that leaves the e-mail alone, or changes only its case (`Ada@…` for `ada@…`): compared normalised, the same test that makes the new address unverified; for a type with no e-mail; for an update refused |
48
50
  | `user.secondFactorEnabled` | `secondFactor.activate`, once the first code made the factor active | for `enroll`, which leaves the factor waiting and asked for nowhere; for a code refused |
49
51
  | `user.secondFactorDisabled` | `secondFactor.disable`, when it removed an **active** factor | for a user who had no factor, a factor still waiting for its first code — it was never asked for — or a second `disable` |
50
52
  | `user.recoveryCodesRegenerated` | `secondFactor.regenerateRecoveryCodes`: the codes the user held stopped working | for `activate`, whose first codes come with `user.secondFactorEnabled`; for a code refused |
@@ -52,9 +54,13 @@ The words — **user event**, **listener** — are defined in
52
54
  | `user.deleted` | `delete`, when it deleted the user | for a replay that finds nobody, or an id of another user type |
53
55
 
54
56
  A reset whose link verifies the e-mail sends both, `user.passwordReset`
55
- first. Switch on `type`: the eight are a closed set, and TypeScript refuses a
56
- ninth. A new type is a compile error in a `switch` that exhausts them — the two
57
- recovery-code types, added in 0.10, broke such a `switch` until it handled them.
57
+ first. **A reset does not also send `user.passwordChanged`**: a listener that
58
+ cares about every new password handles both types, as the `switch` below
59
+ does — one event per write, rather than two a listener would have to tell
60
+ apart. Switch on `type`: the ten are a closed set, and TypeScript refuses an
61
+ eleventh. A new type is a compile error in a `switch` that exhausts them — the
62
+ two recovery-code types, added in 0.10, and the two change types, added in
63
+ 0.14, each broke such a `switch` until it handled them.
58
64
 
59
65
  ```ts
60
66
  function onUserEvent(event: UserEvent): void {
@@ -64,7 +70,10 @@ function onUserEvent(event: UserEvent): void {
64
70
  case 'user.emailVerified':
65
71
  return unlockFeatures(event.userId);
66
72
  case 'user.passwordReset':
67
- return alertSecurityTeam(event.userId);
73
+ case 'user.passwordChanged':
74
+ return noticeTheUser(event); // @nxgt/janus-mail's passwordChanged — see below
75
+ case 'user.emailChanged':
76
+ return noticeTheFormerAddress(event); // to event.formerEmail — see below
68
77
  case 'user.secondFactorEnabled':
69
78
  case 'user.secondFactorDisabled':
70
79
  return noticeTheUser(event); // @nxgt/janus-mail's twoFactorEnabled or twoFactorDisabled
@@ -114,14 +123,59 @@ With several user types, the count is the type's:
114
123
  `auth.patient.secondFactor.recoveryCodesLeft(event.userId)`, on
115
124
  `event.userType`.
116
125
 
126
+ ### Telling the user their password or e-mail changed
127
+
128
+ A password changed by someone else, or an e-mail moved to their inbox, is how
129
+ an account is taken over — so tell the user each time. For a password, read
130
+ the user and write to their address. For an e-mail, write to the **former**
131
+ address: the new one belongs to whoever changed it. The event carries it,
132
+ since nothing else keeps it once the write landed:
133
+
134
+ ```ts
135
+ // `mail` is janusMail({ … }), and the user schema holds a name and a locale.
136
+ const auth = janus({
137
+ ...config,
138
+ async events(event) {
139
+ if (event.type === 'user.passwordChanged') {
140
+ const user = await auth.get(event.userId);
141
+ await mail.passwordChanged({ name: user.name, locale: user.locale, email: user.email });
142
+ }
143
+ if (event.type === 'user.emailChanged' && event.formerEmail != null) {
144
+ const user = await auth.get(event.userId);
145
+ await mail.emailChanged({
146
+ name: user.name,
147
+ locale: user.locale,
148
+ formerEmail: event.formerEmail, // to the inbox the account just left
149
+ newEmail: user.email,
150
+ });
151
+ }
152
+ },
153
+ });
154
+ ```
155
+
156
+ `formerEmail` is `null` for a user who had no e-mail before the update:
157
+ there is no former inbox to tell. The example assumes a required e-mail; with
158
+ an optional one, an update that removes it sends the event too, and the user
159
+ read back has no `newEmail` to name — check `user.email` before the notice. Pass the same notices on `user.passwordReset`
160
+ too, if a reset should be told as well.
161
+
117
162
  ## What an event carries
118
163
 
119
- **The user named by id, and nothing else.** No login, no e-mail, no field of
120
- the schema, no password or hash, no session or one-time token. An event can
164
+ **The user named by id, and nothing else** — with one exception, below. No
165
+ login, no field of the schema, no password or hash, no session or one-time
166
+ token. An event can
121
167
  land in a queue, a log or another company's endpoint; whoever receives it
122
168
  reads the rest from where it is kept — `auth.get(event.userId)` — if they
123
169
  may. A user deleted since is `NOT_FOUND` there, which is the answer.
124
170
 
171
+ **The exception is `formerEmail`, on `user.emailChanged` only**: the address
172
+ the user had before the update, as it was stored, or `null` when they had
173
+ none. Once the write landed it is kept nowhere else, and a notice to the
174
+ inbox the account just left needs it. It is absent from every other type.
175
+ `@nxgt/janus-webhooks` does not carry it: it strips it before the queue, so
176
+ it reaches no endpoint, no queue and no `onGivingUp` report — send the
177
+ notice from the listener, in the process that wrote.
178
+
125
179
  `id` is minted for the event, a UUIDv7 that sorts by time. Deliver on it:
126
180
  a queue job id, a primary key, a webhook's `webhook-id` header. Two events
127
181
  never share one.
@@ -148,15 +202,18 @@ Where the listener runs within a flow, and what an outage does to it:
148
202
  | `create` | after the insert — its last step | — |
149
203
  | `signUp` | after the insert, **before** the session is opened | fails the call; the event is already sent |
150
204
  | `verifyEmail.confirm` | after the write — the flow's last step | — |
205
+ | `update` | after the write — the flow's last step | — |
206
+ | `changePassword`, `setPassword` | **after** the reset links and the second-factor challenges left open are spent | fails the call; the event is sent all the same, from a `finally` |
151
207
  | `signInCode.confirm` | after the write, **before** the session or the second-factor challenge is opened | fails the call; the event is already sent |
152
208
  | `secondFactor.activate`, `secondFactor.regenerateRecoveryCodes`, `secondFactor.disable` | after the write — the flow's last step | — |
153
209
  | `secondFactor.recover` | after the recovery code is spent and the session opened — or failed to open | fails the call; the recovery code is spent and the event sent all the same, from a `finally` |
154
- | `resetPassword.confirm` | **after** the sessions opened with the old password are revoked and the second-factor challenges left open are spent | fails the call; the events are sent all the same, from a `finally` |
210
+ | `resetPassword.confirm` | **after** the sessions opened with the old password are revoked, and the other reset links and the second-factor challenges left open are spent | fails the call; the events are sent all the same, from a `finally` |
155
211
  | `delete` | **after** the user's sessions and one-time tokens are removed, and the relation tuples naming them when `relations` is wired | fails the call; the event is sent all the same, from a `finally` |
156
212
 
157
213
  So a listener that takes its time never leaves an old session alive after a
158
- reset, and one that checks permissions on `user.deleted` finds the tuples
159
- gone. A retry could not send a lost event either way: `delete` then finds
214
+ reset, one that hears of a password changed finds the older reset links
215
+ already spent, and one that checks permissions on `user.deleted` finds the
216
+ tuples gone. A retry could not send a lost event either way: `delete` then finds
160
217
  nobody, and the reset link is spent.
161
218
 
162
219
  `occurredAt` is the write's own time: the `createdAt` or `updatedAt` it
@@ -231,6 +288,8 @@ type UserEventType =
231
288
  | 'user.created'
232
289
  | 'user.emailVerified'
233
290
  | 'user.passwordReset'
291
+ | 'user.passwordChanged'
292
+ | 'user.emailChanged'
234
293
  | 'user.secondFactorEnabled'
235
294
  | 'user.secondFactorDisabled'
236
295
  | 'user.recoveryCodesRegenerated'
@@ -243,6 +302,7 @@ interface UserEvent {
243
302
  readonly occurredAt: Date; // when the write landed: the createdAt or updatedAt written, or the time read just before a deletion
244
303
  readonly userId: Id;
245
304
  readonly userType: string;
305
+ readonly formerEmail?: string | null; // on user.emailChanged only: the address before, null for none
246
306
  }
247
307
 
248
308
  type UserEventListener = (event: UserEvent) => void | Promise<void>;
@@ -257,5 +317,6 @@ see [troubleshooting](../troubleshooting.md#janus-events-must-be-a-function-that
257
317
 
258
318
  - [E-mail verification and password reset](email-flows.md) — the flows that send `user.emailVerified` and `user.passwordReset`
259
319
  - [The second factor](second-factor.md) — `activate` and `disable`, which send `user.secondFactorEnabled` and `user.secondFactorDisabled`; [recovery codes](second-factor.md#recovery-codes), which send `user.recoveryCodesRegenerated` and `user.recoveryCodeUsed`
260
- - [Users](users.md) — `create`, `signUp`, `delete`
320
+ - [Users](users.md) — `create`, `signUp`, `update`, `delete`
321
+ - [Passwords](passwords.md) — `changePassword` and `setPassword`, which send `user.passwordChanged`
261
322
  - [Troubleshooting](../troubleshooting.md) — `JANUS_EVENT_FAILED`
@@ -1,7 +1,8 @@
1
1
  # Password hashing
2
2
 
3
- This page is for choosing a password hasher, moving from one to another, and
4
- importing hashes written by another system. The login and the length policy
3
+ This page is for choosing a password hasher, moving from one to another,
4
+ importing hashes written by another system, and how `signIn` throttles
5
+ password guessing. The login and the length policy
5
6
  are on the [users](users.md#options) page.
6
7
 
7
8
  ```ts
@@ -122,6 +123,125 @@ Write each imported user's hash as their `PasswordRecord` with your store's
122
123
  scrypt the first time they sign in. Wire one verifier
123
124
  per prefix the old system wrote (`$2a$`, `$2b$`, `$2y$` for bcrypt).
124
125
 
126
+ ## Password guessing is throttled
127
+
128
+ **`signIn` counts the passwords tried at each login, and past ten in a
129
+ 15-minute window it refuses every one — the right password included — until
130
+ the window ends.** On by default. The refusal is the same `CREDENTIALS_INVALID`
131
+ as a wrong password, with `reason: 'throttled'` for your logs and
132
+ `retryAfter`, the seconds until the next window, for the client:
133
+
134
+ ```ts
135
+ import { JanusError } from '@nxgt/janus';
136
+
137
+ try {
138
+ return await auth.signIn({ email, password });
139
+ } catch (error) {
140
+ if (error instanceof JanusError && error.code === 'CREDENTIALS_INVALID') {
141
+ const headers = error.retryAfter === undefined
142
+ ? undefined
143
+ : { 'retry-after': String(error.retryAfter) };
144
+ return Response.json({ code: error.code, retryAfter: error.retryAfter }, { status: 401, headers });
145
+ }
146
+ throw error;
147
+ }
148
+ ```
149
+
150
+ [`janusErrors()`](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus-hono/docs/guide/routes.md#sign-up-and-sign-in) in
151
+ `@nxgt/janus-hono` and `janusGraphQLError()` in `@nxgt/janus-graphql` do this
152
+ for you, `Retry-After` header included.
153
+
154
+ What it does, precisely:
155
+
156
+ - **Nothing locks.** The next window signs in: no number of wrong
157
+ passwords blocks an account beyond its window. **But the right password is
158
+ refused while the login is throttled**, so somebody who knows only a login
159
+ can keep its password sign-in shut by trying ten passwords every window.
160
+ That is the price of counting per login; limit per client address (below),
161
+ and a [sign-in code](sign-in-code.md), when you wire them, still opens
162
+ the account.
163
+ - **Per login, known or not.** A login nobody holds is counted as a
164
+ registered one is — `unknownLogin` ten times, then `throttled` — so the
165
+ throttle does not say which logins exist; an unknown login is still
166
+ compared against a dummy hash, as before. The login is counted as
167
+ [`password.normalize`](users.md#passwordnormalize) writes it, within its
168
+ user type: `ADA@example.test` and `ada@example.test` share one count.
169
+ - **Counted before anything is compared**, by the store, in one write per
170
+ attempt: of twenty passwords tried at once, exactly ten are compared. A
171
+ throttled attempt compares nothing, so it costs no hashing either.
172
+ - **A sign-in that opens a session starts the count again** for that
173
+ login: the right password of an active user — and, with a second factor
174
+ active, only once its code or a recovery code opens the session. A
175
+ password that only opens a challenge restarts nothing, so knowing the
176
+ password buys no more than ten challenges per window.
177
+ - **The windows are fixed slices of the clock**, not sliding: ten attempts
178
+ at the end of one window and ten at the start of the next are allowed.
179
+
180
+ Change the limit or the window, or turn it off — `SignInConfig` and
181
+ `SignInThrottleConfig` are the option's types, for a wrapper of your own:
182
+
183
+ ```ts
184
+ janus({ ..., signIn: { throttle: { attempts: 5, window: '1h' } } });
185
+ janus({ ..., signIn: { throttle: false } }); // counts nothing: limit signIn yourself
186
+ ```
187
+
188
+ ### Where the counts live
189
+
190
+ In the tokens store, as `secondFactor` tokens that belong to no user: the
191
+ store needs no new method, and an adapter that passes the conformance suite
192
+ counts correctly. Each is named by a keyed hash of the login, never the login
193
+ — keyed by your `secondFactor` keys when you wire them, and by a fixed key
194
+ otherwise, which only keeps the login out of plain sight: a dump of the
195
+ tokens then tells which logins were tried, for a guessed list. Each expires
196
+ two windows after its window starts — half an hour by default — **Redis and MongoDB drop it then;
197
+ PostgreSQL keeps it** until something deletes it, and every login tried,
198
+ registered or not, adds a row per window. Schedule a delete with
199
+ `@nxgt/janus-drizzle`:
200
+
201
+ ```sql
202
+ delete from tokens where expires_at < now() - interval '1 hour'; -- janus.tokens when your tables have a schema of their own
203
+ ```
204
+
205
+ `tokens` has no index on `expires_at`, so the delete scans the table: add
206
+ `create index on tokens (expires_at)` in a migration of yours if it grows.
207
+
208
+ The key is your first `secondFactor` key: wiring
209
+ `secondFactor` for the first time, or putting a new key first, starts every
210
+ login's count again. **A Redis tokens store that is flushed, or evicts keys under
211
+ memory pressure, forgets them** and every count starts again: give Redis
212
+ `maxmemory-policy noeviction` (see `@nxgt/janus-redis`).
213
+
214
+ ### When the store cannot count
215
+
216
+ `signIn` throws `STORE_FAILED` — answer 503. It never answers a refusal the
217
+ visitor could not have caused, and never lets a password through uncounted:
218
+ **the throttle fails closed**, so a tokens-store outage stops password
219
+ sign-ins as a users-store outage would. A sign-in whose password was right and
220
+ whose count could not start again also throws `STORE_FAILED`, before any
221
+ session is opened.
222
+
223
+ The store's round-trips are observable, as its latency is: a login that
224
+ signed in this window costs a few more probes than one nobody holds.
225
+
226
+ ### What you still limit yourself
227
+
228
+ The throttle counts per login. It does not see:
229
+
230
+ - **One password tried against many logins** (password spraying), nor many
231
+ logins from one client: limit `signIn` **per client address** in front of
232
+ the call, with the limiter you already run.
233
+ - `changePassword`, which compares the current password too: limit it per
234
+ user.
235
+ - `resetPassword.request` and `signInCode.request`, which send e-mail: limit
236
+ them per address.
237
+
238
+ ```ts
239
+ if (!(await limiter.consume(`sign-in:${clientAddress}`))) {
240
+ return new Response(null, { status: 429, headers: { 'retry-after': '900' } });
241
+ }
242
+ const signedIn = await auth.signIn({ email, password });
243
+ ```
244
+
125
245
  ## What never happens
126
246
 
127
247
  - The plain password is never stored, and never appears in an error message.
@@ -133,4 +253,4 @@ per prefix the old system wrote (`$2a$`, `$2b$`, `$2y$` for bcrypt).
133
253
  ## See also
134
254
 
135
255
  - [Users](users.md) — `signIn`, `setPassword`, `changePassword`
136
- - [Errors](errors.md) — `CREDENTIALS_INVALID` and its `reason`
256
+ - [Errors](errors.md) — `CREDENTIALS_INVALID`, its `reason` and `retryAfter`
@@ -306,8 +306,10 @@ try {
306
306
  ```
307
307
 
308
308
  Five attempts at a million values is a one-in-200,000 chance per password
309
- guessed right. A new challenge takes a new sign-in, with the password, so the
310
- attempts are bounded by your sign-in rate limit too.
309
+ guessed right. A new challenge takes a new sign-in, with the password, and
310
+ `signIn`'s throttle allows ten per login per 15-minute window — so the
311
+ password alone buys at most ten challenges a window; a limit per client is
312
+ yours to add.
311
313
 
312
314
  A call made through **another user type's** API — `auth.staff.secondFactor.confirm`
313
315
  for a patient's challenge — answers `TOKEN_UNKNOWN` and compares nothing, but
@@ -96,6 +96,7 @@ that cuts an emoji in half, say.
96
96
  | `tokens.resetPassword` | `Duration` | `'1h'` | How long a reset token lives |
97
97
  | `tokens.signInCode` | `Duration` | `'10m'` | How long an e-mailed sign-in code and its challenge live. See [sign-in codes](sign-in-code.md) |
98
98
  | `tokens.stepUp` | `Duration` | `'10m'` | How long a step-up's challenge — and its e-mailed code — lives. See [step-up](step-up.md) |
99
+ | `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) |
99
100
  | `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) |
100
101
  | `events` | `UserEventListener` | none | Called with every user event — `user.created`, `user.deleted`, … — after the write, awaited. See [user events](events.md) |
101
102
 
@@ -178,7 +179,7 @@ In the one-type form these are on `auth` itself; with several types, on
178
179
  | `find(id)` | the user, or `null` — for a malformed id, an unknown one, or one of another type | |
179
180
  | `get(id)` | the user | `NOT_FOUND` |
180
181
  | `list({ after?, limit? })` | `CursorPage<User>`, in creation order | `INVALID_CURSOR` |
181
- | `update(user, patch, { ifVersion? })` | the user as written | `USER_INVALID`, `LOGIN_TAKEN`, `VERSION_CONFLICT`, `NOT_FOUND` |
182
+ | `update(user, patch, { ifVersion? })` | the user as written; sends `user.emailChanged` when the e-mail changed | `USER_INVALID`, `LOGIN_TAKEN`, `VERSION_CONFLICT`, `NOT_FOUND` |
182
183
  | `setActive(user, active, { ifVersion? })` | the user as written | `VERSION_CONFLICT`, `NOT_FOUND` |
183
184
  | `delete(user)` | `true`, or `false` for an unknown id or one of another type | |
184
185
 
@@ -187,16 +188,20 @@ With a `password`, besides:
187
188
  | Method | Answers | Rejects with |
188
189
  | --- | --- | --- |
189
190
  | `signUp(fields & { password })` | `{ status: 'signedIn', user, session, token }` | `USER_INVALID`, `PASSWORD_TOO_SHORT`, `LOGIN_TAKEN` |
190
- | `signIn({ [login]: string, password })` | `{ status: 'signedIn', user, session, token }` — or, with `secondFactor` configured and the user's factor active, `{ status: 'secondFactor', challenge, expiresAt, userId }`: switch on `status` | `CREDENTIALS_INVALID`, `USER_INACTIVE`, `HASH_UNSUPPORTED` |
191
+ | `signIn({ [login]: string, password })` | `{ status: 'signedIn', user, session, token }` — or, with `secondFactor` configured and the user's factor active, `{ status: 'secondFactor', challenge, expiresAt, userId }`: switch on `status` | `CREDENTIALS_INVALID` — with `retryAfter` once the login is throttled — `USER_INACTIVE`, `HASH_UNSUPPORTED` |
191
192
  | `findByLogin(login)` | the user, or `null`; the login is normalised first, and one holding a NUL or a lone surrogate is nobody's | |
192
- | `setPassword(user, password, { ifVersion? })` | the user — an admin's call; spends the user's second-factor challenges still waiting, and signs nobody out | `PASSWORD_TOO_SHORT` |
193
- | `changePassword(user, { current, next }, { ifVersion? })` | the user — the user's own call; spends the user's second-factor challenges still waiting | `CREDENTIALS_INVALID`, `PASSWORD_TOO_SHORT` |
193
+ | `setPassword(user, password, { ifVersion? })` | the user — an admin's call; spends the user's reset links and second-factor challenges still waiting, and signs nobody out; sends `user.passwordChanged` | `PASSWORD_TOO_SHORT` |
194
+ | `changePassword(user, { current, next }, { ifVersion? })` | the user — the user's own call; spends the user's reset links and second-factor challenges still waiting; sends `user.passwordChanged` | `CREDENTIALS_INVALID`, `PASSWORD_TOO_SHORT` |
194
195
 
195
196
  With `secondFactor` configured, a type with a password also answers
196
197
  `secondFactor.enroll`, `activate`, `disable` and `confirm` — see
197
198
  [the second factor](second-factor.md).
198
199
 
199
- Every method may also reject with `STORE_FAILED`. A `user` argument is a user
200
+ Every method may also reject with `STORE_FAILED`. `setPassword` and
201
+ `changePassword` spend the user's reset links and second-factor challenges
202
+ after the write, so a `STORE_FAILED` there may come with the password
203
+ already written: a retried `changePassword` with the old `current` then
204
+ answers `CREDENTIALS_INVALID`. A `user` argument is a user
200
205
  or its id (`UserRef = string | { id: string }`).
201
206
 
202
207
  ```ts
@@ -216,7 +221,11 @@ await auth.delete(renamed); // true
216
221
  The patch is spread over the stored fields and the result is checked against
217
222
  the schema, so a patch can never leave a user the schema would refuse.
218
223
  Changing the e-mail sets `emailVerified` back to `false`, and moves the login
219
- with it when the e-mail is the login.
224
+ with it when the e-mail is the login. It also sends a
225
+ [`user.emailChanged` event](events.md#telling-the-user-their-password-or-e-mail-changed)
226
+ carrying `formerEmail`, the address before — so the notice goes to the inbox
227
+ the account just left. A change of case only (`Ada@…` for `ada@…`) is no
228
+ change, and sends nothing.
220
229
 
221
230
  ### `ifVersion`
222
231
 
@@ -289,6 +298,6 @@ fields against your schema, not that `password` is a string.
289
298
  ## See also
290
299
 
291
300
  - [Sessions](sessions.md) — `authenticate`, the cookie, signing out
292
- - [User events](events.md) — what `create`, `signUp` and `delete` send
301
+ - [User events](events.md) — what `create`, `signUp`, `update`, `setPassword`, `changePassword` and `delete` send
293
302
  - [Errors](errors.md) — every code, and the status it deserves
294
303
  - [Writing an adapter](adapters.md) — the identity stores behind `store`
@@ -52,14 +52,14 @@ either finds this row.
52
52
  | **recovery code** | One of ten single-use codes `secondFactor.activate` and `secondFactor.regenerateRecoveryCodes` answer **once** — ten characters, shown `xxxxx-xxxxx` — that `secondFactor.recover` takes instead of a TOTP code, for a user whose authenticator app is gone. Stored only as keyed hashes. Always written in full: *code* alone is a one-time code, and a recovery code is never one | "backup code", "scratch code", "recovery key" |
53
53
  | **attempt** | One code tried against a challenge, counted by `countAttempt` in the token's `attempts` before the code is checked. A challenge takes five, a second factor's or a sign-in code's | "try"; "retry" is a repeated write, never a guess |
54
54
  | **challenge** | A secret the visitor keeps, redeemed once with a code — which is what *confirm* means for it. `signIn` answers one instead of a session when the user's second factor is active, for `secondFactor.confirm` — or `secondFactor.recover`, with a recovery code; `signInCode.request` answers one beside the sign-in code, for `signInCode.confirm`; `stepUp.request` answers one for `stepUp.confirm`. Stored as its hash, as a one-time token of kind `secondFactor`, `signInCode` or `stepUp`: five minutes or ten by default, and five attempts | "MFA token", "pending session", "ticket" |
55
- | **spent** | A one-time token or a challenge that can no longer be redeemed — `spentAt` set, `TOKEN_SPENT`, and never unset. Spent by its redemption, by its fifth attempt (a wrong code, or a call through another user type's API), by a redemption after it expired, by a refusal that ends it (`USER_INACTIVE`, `TOKEN_STALE`, `SECOND_FACTOR_NOT_ENROLLED`), or by what ends it early: the next `signInCode.request` or `stepUp.request` for the same user (at most one of each is live at a time), or a password written by `resetPassword.confirm`, `setPassword` or `changePassword` (every second-factor challenge left waiting) | "used up", "invalidated"; "consumed" only in `consumeToken`'s name; "revoked" is a session's |
55
+ | **spent** | A one-time token or a challenge that can no longer be redeemed — `spentAt` set, `TOKEN_SPENT`, and never unset. Spent by its redemption, by its fifth attempt (a wrong code, or a call through another user type's API), by a redemption after it expired, by a refusal that ends it (`USER_INACTIVE`, `TOKEN_STALE`, `SECOND_FACTOR_NOT_ENROLLED`), or by what ends it early: the next `signInCode.request`, `stepUp.request` or `resetPassword.request` for the same user (at most one of each is live at a time), or a password written by `resetPassword.confirm`, `setPassword` or `changePassword` (every reset link and every second-factor challenge left waiting) | "used up", "invalidated"; "consumed" only in `consumeToken`'s name; "revoked" is a session's |
56
56
  | **step** | The thirty-second period a TOTP code belongs to. `lastStep` is the step of the last code accepted, and only a later step counts: that is why a code is accepted once | "window", "period", "counter", in prose |
57
57
  | **seal**, **sealing key** | To seal is to encrypt a TOTP secret — AES-256-GCM, bound to the user's id — before a store sees it. A sealing key is one `{ id, key }` of `secondFactor.keys`: the first seals, every one opens | "encrypt", "encryption key", "master key", "pepper" |
58
58
  | **token** | Never alone in prose: a *session token* or a *one-time token*. The `tokens` store and the `TOKEN_*` codes are one-time tokens only | |
59
59
  | **e-mail flow** | `verifyEmail` or `resetPassword`: send a one-time token, then confirm it. `signInCode` sends a one-time code instead | |
60
60
  | **issued** | What a flow answers for the application to send: an `IssuedToken` from `verifyEmail.send` or `resetPassword.request`, an `IssuedCode` from `signInCode.request` — the address with it. `@nxgt/janus-mail` takes it as it is | "result", "payload" |
61
61
  | **notice** | An e-mail that tells a user of a change and asks for nothing: *password changed*, *e-mail changed* — the latter sent to the former address — and *two-factor enabled*, *two-factor disabled*. `@nxgt/janus-mail` sends all four | "alert", "notification" |
62
- | **user event** | What happened to a user, once it is written: `user.created`, `user.emailVerified`, `user.passwordReset`, `user.secondFactorEnabled`, `user.secondFactorDisabled`, `user.recoveryCodesRegenerated`, `user.recoveryCodeUsed`, `user.deleted` — a `UserEvent`, naming the user by id alone, with an `id` of its own. See [user events](events.md) | "webhook" — a webhook is one way to deliver it; "event" alone where it could be read as `@nxgt/janus-telemetry`'s audit log record — on a page about user events, "the event" is fine |
62
+ | **user event** | What happened to a user, once it is written: `user.created`, `user.emailVerified`, `user.passwordReset`, `user.passwordChanged`, `user.emailChanged`, `user.secondFactorEnabled`, `user.secondFactorDisabled`, `user.recoveryCodesRegenerated`, `user.recoveryCodeUsed`, `user.deleted` — a `UserEvent`, naming the user by id alone — `user.emailChanged` adds `formerEmail` — with an `id` of its own. See [user events](events.md) | "webhook" — a webhook is one way to deliver it; "event" alone where it could be read as `@nxgt/janus-telemetry`'s audit log record — on a page about user events, "the event" is fine |
63
63
  | **step-up**, **fresh** | A step-up is a signed-in user proving again who they are before a sensitive action — `stepUp.request`, then `stepUp.confirm`, with an e-mailed code or, when their second factor is active, their app's — which moves the session's `authenticatedAt` to now and opens no session. A session is **fresh** for `maxAge` after it proved who it is, at the sign-in or by a step-up; `assertFresh` refuses an older one with `STEP_UP_REQUIRED`. See [step-up](step-up.md) | "re-authentication", "sudo mode", "elevated session"; "recent sign-in" once a step-up counts too |
64
64
  | **listener** | The one function `janus({ events })` hands every user event to, after the write and awaited | "handler", "hook", "subscriber" |
65
65
 
package/docs/roadmap.md CHANGED
@@ -13,6 +13,12 @@ Nothing yet.
13
13
 
14
14
  ## Later
15
15
 
16
+ - **Collecting lapsed one-time tokens** — `collectExpired()` deletes lapsed
17
+ sessions only. On a store with no TTL (PostgreSQL), lapsed tokens stay
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
20
+ core do it.
21
+
16
22
  - **More official adapters** — the ports are cut where atomicity is not
17
23
  required, so users, sessions and permission tuples can each live in the
18
24
  database that suits them. MongoDB is the first adapter
@@ -46,6 +52,9 @@ Nothing yet.
46
52
  Valibot, ArkType); none is imposed as a peer.
47
53
  - **A silent hasher fallback** — a user type with a password and no `hasher`
48
54
  is refused at wiring, rather than hashed with something you did not choose.
55
+ - **An account lockout** — a login past its attempts waits for the next
56
+ window, and no number of wrong passwords blocks an account for longer:
57
+ a lockout would let anyone lock anyone out.
49
58
  - **Answering `null` or `false` on an outage** — a store that cannot answer
50
59
  throws `STORE_FAILED`, and a permission walk past `maxDepth` throws
51
60
  `PERMISSION_DEPTH`. Neither will become a denial: that turns an outage into
@@ -61,6 +70,31 @@ Nothing yet.
61
70
  The last ten, newest first, each with the version it came in. Everything
62
71
  before is in the [CHANGELOG](../CHANGELOG.md).
63
72
 
73
+ - **Password guessing throttled per login, v0.14.0** — past ten passwords
74
+ tried at one login in a 15-minute window, `signIn` answers
75
+ `CREDENTIALS_INVALID` with `reason: 'throttled'` and `retryAfter`, the right
76
+ password included, until the window ends. Nothing locks; a login nobody
77
+ holds is counted alike; a password sign-in that opens a session —
78
+ after its second factor, when one is active — starts the count again.
79
+ On by default: `signIn: { throttle: { attempts, window } }` changes it,
80
+ `signIn: { throttle: false }` turns it off. A tokens store that cannot
81
+ count fails the sign-in with `STORE_FAILED`. No change for adapters: the
82
+ counts are `secondFactor` tokens, counted by `TokenStore.countAttempt`.
83
+ - **A password or an e-mail changed, in the user events, v0.14.0** —
84
+ `user.passwordChanged`, sent by `changePassword` and `setPassword` once the
85
+ older reset links are spent (a reset stays `user.passwordReset` alone), and
86
+ `user.emailChanged`, sent by an `update` that changed the e-mail, carrying
87
+ `formerEmail` — the one event with more than the user's id — so a notice
88
+ can reach the inbox the account just left, as `@nxgt/janus-mail`'s
89
+ `passwordChanged` and `emailChanged` notices do. Breaking for a `switch`
90
+ that exhausts `UserEventType`.
91
+ - **An old reset link stops working, v0.13.0** — `resetPassword.request`
92
+ spends the links sent before, so only the last e-mail's works, and writing
93
+ a password — a link's `confirm`, `changePassword`, `setPassword` — spends
94
+ every reset link still live, so a link sent before the change cannot
95
+ replace the new password. Both answer `TOKEN_SPENT`; an outage spending
96
+ them fails the call with `STORE_FAILED`. No change for adapters: the
97
+ existing `TokenStore.spendUserTokens` does it.
64
98
  - **Confirm an action with a code (step-up), v0.12.0** —
65
99
  `auth.stepUp.request(user)` and `stepUp.confirm(request, challenge, code)`:
66
100
  a signed-in user proves again who they are before a sensitive action, with
@@ -120,29 +154,3 @@ before is in the [CHANGELOG](../CHANGELOG.md).
120
154
  Webhooks specification (HMAC-SHA256, secrets that rotate) and posted to
121
155
  your endpoints, retried with backoff, and reported to `onGivingUp` when
122
156
  given up — never dropped in silence. `verifyWebhook` is the receiving side.
123
- - **User events, v0.8.0** — `janus({ events })` takes one listener, called
124
- with `user.created`, `user.emailVerified`, `user.passwordReset` and
125
- `user.deleted` once the write landed, and awaited before the flow answers.
126
- An event names the user by id alone, with a UUIDv7 of its own to deliver
127
- it once; a listener that throws fails no flow and is a
128
- `JANUS_EVENT_FAILED` warning.
129
- - **At most one sign-in code live per user, and challenges that end when
130
- they should, v0.7.0** — `signInCode.request` spends the codes sent before,
131
- even when requests race, so only the last e-mail's works; writing a
132
- password spends the second-factor challenges left waiting, and a sign-in
133
- still running when it lands is refused; another user type's `confirm` spends a challenge
134
- at its fifth attempt; `verifyEmail.confirm` and `resetPassword.confirm`
135
- check the e-mail again on the record they write. `SecondFactorRequired`
136
- carries `userId`, for logs and rate limits. For adapters:
137
- `TokenStore.spendUserTokens(userId, kind, at, except?)`, with four new conformance
138
- cases, implemented in `@nxgt/janus-drizzle`, `@nxgt/janus-mongo` and
139
- `@nxgt/janus-redis` — and the port now says a read sees every write that
140
- completed before it: never a secondary or a read replica.
141
- - **Sign in with a code sent by e-mail, v0.6.0** —
142
- `auth.<type>.signInCode.request(email)` answers a six-digit code to send
143
- and a challenge to keep with the visitor, or `null` for nobody — never
144
- saying which; `signInCode.confirm(challenge, code)` marks the e-mail
145
- verified and opens the session. On every user type with an e-mail, one
146
- without a password included; an active second factor is still asked for.
147
- A challenge lives ten minutes (`tokens.signInCode`) and takes five
148
- attempts, and only the code's hash is stored, keyed by the challenge.