@nxgt/janus 0.25.0 → 0.27.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 (33) hide show
  1. package/README.md +22 -5
  2. package/dist/auth/config/reserved.d.ts +1 -1
  3. package/dist/auth/config/reserved.d.ts.map +1 -1
  4. package/dist/auth/email-flows/any-type-change-email.d.ts +18 -0
  5. package/dist/auth/email-flows/any-type-change-email.d.ts.map +1 -0
  6. package/dist/auth/email-flows/change-email-token.d.ts +6 -1
  7. package/dist/auth/email-flows/change-email-token.d.ts.map +1 -1
  8. package/dist/auth/email-flows/change-email.d.ts +6 -0
  9. package/dist/auth/email-flows/change-email.d.ts.map +1 -1
  10. package/dist/auth/index.d.ts +1 -1
  11. package/dist/auth/index.d.ts.map +1 -1
  12. package/dist/auth/janus.d.ts.map +1 -1
  13. package/dist/auth/sign-in-attempts.d.ts +5 -0
  14. package/dist/auth/sign-in-attempts.d.ts.map +1 -1
  15. package/dist/auth/types/change-email.d.ts +31 -0
  16. package/dist/auth/types/change-email.d.ts.map +1 -1
  17. package/dist/auth/types/index.d.ts +1 -1
  18. package/dist/auth/types/index.d.ts.map +1 -1
  19. package/dist/auth/types/janus.d.ts +9 -2
  20. package/dist/auth/types/janus.d.ts.map +1 -1
  21. package/dist/auth/types/password.d.ts +8 -2
  22. package/dist/auth/types/password.d.ts.map +1 -1
  23. package/dist/auth/users/change-password.d.ts +13 -0
  24. package/dist/auth/users/change-password.d.ts.map +1 -1
  25. package/dist/index.js +470 -450
  26. package/dist/index.js.map +20 -19
  27. package/docs/README.md +1 -1
  28. package/docs/guide/email-flows.md +14 -0
  29. package/docs/guide/passwords.md +8 -2
  30. package/docs/guide/users.md +3 -3
  31. package/docs/roadmap.md +11 -27
  32. package/docs/troubleshooting.md +15 -12
  33. package/package.json +1 -1
package/docs/README.md CHANGED
@@ -20,7 +20,7 @@ below are defined once, in [Words](guide/vocabulary.md#words).
20
20
  | [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 |
21
21
  | [Devices](guide/devices.md) | You want to tell a user their account was signed in from a new device: wiring `devices`, the `device` each sign-in takes, the device cookie, the second factor, the notice by e-mail, rotating the keys and forgetting devices |
22
22
  | [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, signs in from a new device or is deleted — to queue it, sync it, or send it as a webhook |
23
- | [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 |
23
+ | [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` and `changePassword` throttle password guessing per login, and what you still limit per client |
24
24
 
25
25
  ### Permissions — `@nxgt/janus/permissions`
26
26
 
@@ -265,6 +265,20 @@ is no longer the one it was asked from although nothing spent it: a write
265
265
  outside janus, or a `send` racing an `update`. `confirm` sends `user.emailChanged` (with
266
266
  `formerEmail`) and `user.emailVerified`.
267
267
 
268
+ **With several user types**, a type's own `confirm` answers `TOKEN_UNKNOWN`
269
+ for a token issued to another type's user, and spends it — a later
270
+ confirmation through the right type is then `TOKEN_SPENT`. A page that does
271
+ not know the type calls the root's `confirm`, which reads the user the token
272
+ was issued to and confirms as their type does: same move, same events, same
273
+ errors, and `type` in the answer says which type it was. `send` stays on each
274
+ type. The root's exists when one type has an e-mail, which is why no type may
275
+ be named `changeEmail`.
276
+
277
+ ```ts
278
+ // one anonymous page for every type
279
+ const changed = await auth.changeEmail.confirm(token); // changed.type: 'admin' | 'member'
280
+ ```
281
+
268
282
  **Gate `send`, and tell the former address.** Whoever changes the e-mail
269
283
  takes over every reset: call `send` behind a fresh sign-in or a step-up.
270
284
 
@@ -175,6 +175,14 @@ What it does, precisely:
175
175
  active, only once its code or a recovery code opens the session. A
176
176
  password that only opens a challenge restarts nothing, so knowing the
177
177
  password buys no more than ten challenges per window.
178
+ - **`changePassword` counts in the same budget.** Each call is one more
179
+ password tried at the user's login, counted before `current` is compared
180
+ (a user with no password too, so the answer does not tell them apart), so
181
+ a caller holding a session cannot guess current passwords without limit.
182
+ Past the limit it answers `CREDENTIALS_INVALID` with `reason: 'throttled'`
183
+ and `retryAfter`, the right `current` included, and writes nothing. One
184
+ budget per account: wrong guesses at `signIn` and at `changePassword` add
185
+ up, and a change that is written starts the count again.
178
186
  - **The windows are fixed slices of the clock**, not sliding: ten attempts
179
187
  at the end of one window and ten at the start of the next are allowed.
180
188
 
@@ -228,8 +236,6 @@ The throttle counts per login. It does not see:
228
236
  - **One password tried against many logins** (password spraying), nor many
229
237
  logins from one client: limit `signIn` **per client address** in front of
230
238
  the call, with the limiter you already run.
231
- - `changePassword`, which compares the current password too: limit it per
232
- user.
233
239
  - `resetPassword.request`, `signInCode.request` and `magicLink.request`,
234
240
  which send e-mail — and their `prepare`: [the mail throttle](email-flows.md#requests-that-send-e-mail-are-throttled)
235
241
  counts them per address, but not per client. Limit them per client.
@@ -97,7 +97,7 @@ that cuts an emoji in half, say.
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.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
- | `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) |
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; `changePassword`'s wrong current passwords count in the same budget. `false` counts nothing. See [passwords](passwords.md#password-guessing-is-throttled) |
101
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`, and `changeEmail.send`, which counts the new address too) 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) |
102
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) |
103
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) |
@@ -168,7 +168,7 @@ and `schemaVersion`, with the defaults above. A login is unique **per type**:
168
168
  the same e-mail may hold a patient user and a staff user. A type name is
169
169
  camelCase, and may not be one of `janus()`'s own methods (`authenticate`,
170
170
  `signOut`, `signOutEverywhere`, `findUser`, `getUser`, `cookie`,
171
- `collectExpired`, `types`) — a compile error, and a `TypeError` for a
171
+ `collectExpired`, `types`, `changeEmail`) — a compile error, and a `TypeError` for a
172
172
  JavaScript caller.
173
173
 
174
174
  ## What each user type answers
@@ -196,7 +196,7 @@ With a `password`, besides:
196
196
  | `signIn({ [login]: string, password }, { device? })` | `{ status: 'signedIn', user, session, token, newDevice, deviceToken }` — 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` |
197
197
  | `findByLogin(login)` | the user, or `null`; the login is normalised first, and one holding a NUL or a lone surrogate is nobody's | |
198
198
  | `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` |
199
- | `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` |
199
+ | `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` (`throttled` past the [sign-in throttle](passwords.md#password-guessing-is-throttled), which it counts in), `PASSWORD_TOO_SHORT` |
200
200
 
201
201
  With an e-mail — the field `email` names, password or not — besides:
202
202
 
package/docs/roadmap.md CHANGED
@@ -69,6 +69,17 @@ Nothing yet.
69
69
  The last ten, newest first, each with the version it came in. Everything
70
70
  before is in the [CHANGELOG](../CHANGELOG.md).
71
71
 
72
+ - **`changePassword` counts in the sign-in throttle, v0.27.0** — each call is
73
+ one more password tried at the user's login, in the budget `signIn` counts
74
+ in: past the limit it answers `CREDENTIALS_INVALID` with `reason:
75
+ 'throttled'` and `retryAfter`, the right current password included, and a
76
+ change that is written restarts the count.
77
+ - **Root `changeEmail.confirm` for instances with several user types, v0.26.0** —
78
+ `auth.changeEmail.confirm(token)` confirms a change of e-mail for a page that
79
+ does not know the user's type: it reads the user the link was issued to and
80
+ confirms as their type does. A type's own `confirm` still answers
81
+ `TOKEN_UNKNOWN`, and spends the token, for another type's link; no user type
82
+ may be named `changeEmail`.
72
83
  - **Collecting lapsed one-time tokens, v0.25.0** — `collectExpired()` deletes
73
84
  lapsed sessions and, through a new optional `TokenStore.deleteExpiredTokens`,
74
85
  lapsed one-time tokens too: the sign-in and mail throttles' counts
@@ -138,30 +149,3 @@ before is in the [CHANGELOG](../CHANGELOG.md).
138
149
  in no throttle and spends no link already sent, so accepting an
139
150
  invitation no longer goes through `magicLink.request` and its mail
140
151
  throttle. Privileged: never with an id a request supplied.
141
- - **E-mail requests in two steps, v0.20.0** — `magicLink.prepare(email)`,
142
- `signInCode.prepare(email)` and `resetPassword.prepare(email)` count the
143
- address and look nobody up — the same work for any address — so
144
- `MAIL_THROTTLED` reaches the visitor while `send()`, the lookup and the
145
- issue, runs off their request, where its time tells nothing. `send()`
146
- answers what `request` does, once; a second call is a `TypeError`. A sign-in
147
- code's prepared request carries its `challenge`, minted before the lookup,
148
- so no decoy is needed for an address nobody holds.
149
- - **Several users by id in one query, v0.19.0** — `auth.<type>.findMany(ids)`
150
- answers the type's users in the order of `ids`, each once, and leaves out an
151
- id nobody of that type holds — for a list of rows that would otherwise make
152
- one `find` per row and fill the connection pool. The users store gains an
153
- optional `findUsers`, implemented by the reference store,
154
- `@nxgt/janus-drizzle` 0.7.0 and `@nxgt/janus-mongo` 0.8.0; a store without it
155
- is read with `findUser`, ten at a time, so third-party adapters keep working.
156
- - **Requests that send e-mail are throttled, v0.18.0** —
157
- `janus({ mail: { throttle: { attempts, window } } })`, five per ten
158
- minutes by default and `false` to turn it off. `magicLink.request`,
159
- `signInCode.request` and `resetPassword.request` count per address;
160
- `verifyEmail.send` and an e-mailed `stepUp.request` count per user; each
161
- flow on its own. Past the limit a request throws `MailThrottledError`
162
- (`MAIL_THROTTLED`, 429) with `retryAfter` and issues, spends and rotates
163
- nothing: the last link or code sent still works, so a loop on an address
164
- cannot lock its owner out.
165
- The window is no longer than the shortest default token lifetime. The counts
166
- live in the tokens store, with no port change. `MailConfig` and
167
- `MailThrottleConfig` are exported.
@@ -366,8 +366,8 @@ janus({ user: z.object({ username: z.string() }), password: { login: 'username'
366
366
  Also `janus: "<name>" cannot name a user type — janus() answers a method of that name` and `janus: users declares no user type`.
367
367
 
368
368
  **When:** `janus({ users: {...} })`.
369
- **Why:** each type becomes a property of what `janus()` answers — `auth.staff` — so it must be a name, and not one of the shared methods (`authenticate`, `signOut`, `signOutEverywhere`, `findUser`, `getUser`, `cookie`, `collectExpired`, `types`).
370
- **Fix:** `users: { staffMember: {...} }`, not `'staff-member'` or `cookie`.
369
+ **Why:** each type becomes a property of what `janus()` answers — `auth.staff` — so it must be a name, and not one of the shared methods (`authenticate`, `signOut`, `signOutEverywhere`, `findUser`, `getUser`, `cookie`, `collectExpired`, `types`, `changeEmail`).
370
+ **Fix:** `users: { staffMember: {...} }`, not `'staff-member'`, `cookie` or `changeEmail`.
371
371
 
372
372
  ### `janus: session.lifespan: "<value>" is not a duration; write a number followed by ms, s, m, h or d — for example "15m" or "720h"`
373
373
 
@@ -535,7 +535,7 @@ A runtime without `process.emitWarning` stays silent. Silence it with
535
535
  **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.
536
536
  **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.
537
537
 
538
- **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.
538
+ **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 `STORE_FAILED` from `changePassword` may therefore follow a written change: check with `signIn` using `next` before retrying. A retried `changePassword` with the old `current` answers `CREDENTIALS_INVALID`; the next `resetPassword.request` spends any link left live.
539
539
 
540
540
  ```ts
541
541
  import { JanusError } from '@nxgt/janus';
@@ -738,19 +738,20 @@ return new Response('Wrong e-mail or password', { status: 401 });
738
738
 
739
739
  `CredentialError`, with `reason: 'throttled'` and `retryAfter`.
740
740
 
741
- **When:** `signIn`, after ten passwords were tried at this login in the
742
- current 15-minute window — whatever this password is, **the right one
743
- included**, and whether or not anybody holds the login. Nothing is compared.
741
+ **When:** `signIn` or `changePassword`, after ten passwords were tried at
742
+ this login in the current 15-minute window. The two share one count. Whatever
743
+ the password is, **the right one included**, and whether or not anybody holds
744
+ the login, it is refused. Nothing is compared.
744
745
  **Why:** `signIn` throttles password guessing per login, on by default:
745
746
  counted by the store, in fixed windows of the clock, shared by every process
746
747
  over the same store. Nothing locks: the next window signs in, and a sign-in
747
- that succeeds starts the count again. In tests with `fixedClock`, the window
748
- never ends: advance the clock past `retryAfter`, or wire
749
- `signIn: { throttle: false }`.
748
+ that succeeds, or a `changePassword` that is written, starts the count
749
+ again. In tests with `fixedClock`, the window never ends: advance the clock
750
+ past `retryAfter`, or wire `signIn: { throttle: false }`.
750
751
  **Fix:** tell the visitor to wait `retryAfter` seconds — not that the
751
752
  password is wrong — and answer 401, as `@nxgt/janus-hono`'s `janusErrors()`
752
753
  does, with a `Retry-After` header. If nobody you know tried ten passwords,
753
- somebody is guessing: look for `janus.signIn.throttled` in your logs. To
754
+ somebody is guessing: count the `CredentialError`s with `reason: 'throttled'` your handler sees. To
754
755
  change the limit or turn it off:
755
756
 
756
757
  ```ts
@@ -949,7 +950,7 @@ janus({ ..., hasher: scryptHasher(), verifiers: [bcryptVerifier] }); // a Passwo
949
950
  `TokenError`: `<call>: no such token`, `<call>: the token was already used`, `<call>: the token has expired`.
950
951
 
951
952
  **When:** `verifyEmail.confirm`, `resetPassword.confirm`, `changeEmail.confirm`.
952
- **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, and a change link confirmed through another user type's `changeEmail` or passed to another flow. The defaults are 24 h for `verifyEmail`, 1 h for `resetPassword` and for `changeEmail`. A change link is also spent by a newer `changeEmail.send` for the same user (only the last one works), and by an `update` that changes the user's e-mail.
953
+ **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, and a change link confirmed through another user type's `changeEmail` (which spends it, so the right type then answers `TOKEN_SPENT`) or passed to another flow. The defaults are 24 h for `verifyEmail`, 1 h for `resetPassword` and for `changeEmail`. A change link is also spent by a newer `changeEmail.send` for the same user (only the last one works), and by an `update` that changes the user's e-mail.
953
954
  A reset link answers `resetPassword.confirm: the token was already used` in
954
955
  two more cases: a newer `resetPassword.request` for the same user spent it —
955
956
  only the last link sent works, so a visitor who asked twice and clicked the
@@ -958,7 +959,9 @@ sent, by another link's `confirm`, `changePassword` or `setPassword`. When two
958
959
  requests race, even the last link can be spent: at most one survives,
959
960
  sometimes none.
960
961
  **Fix:** answer 400 and offer to send a new link — and tell the visitor to use
961
- the latest e-mail. To change the lifetimes:
962
+ the latest e-mail. With several user types, confirm a change link from a page
963
+ that does not know the type with the root's `auth.changeEmail.confirm(token)`,
964
+ never through a guessed type. To change the lifetimes:
962
965
 
963
966
  ```ts
964
967
  janus({ ..., tokens: { verifyEmail: '72h', resetPassword: '2h' } });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxgt/janus",
3
- "version": "0.25.0",
3
+ "version": "0.27.0",
4
4
  "description": "Embeddable, type-safe identities and permissions: bring your own database",
5
5
  "license": "MIT",
6
6
  "type": "module",