@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.
- package/README.md +22 -5
- package/dist/auth/config/reserved.d.ts +1 -1
- package/dist/auth/config/reserved.d.ts.map +1 -1
- package/dist/auth/email-flows/any-type-change-email.d.ts +18 -0
- package/dist/auth/email-flows/any-type-change-email.d.ts.map +1 -0
- package/dist/auth/email-flows/change-email-token.d.ts +6 -1
- package/dist/auth/email-flows/change-email-token.d.ts.map +1 -1
- package/dist/auth/email-flows/change-email.d.ts +6 -0
- package/dist/auth/email-flows/change-email.d.ts.map +1 -1
- package/dist/auth/index.d.ts +1 -1
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/janus.d.ts.map +1 -1
- package/dist/auth/sign-in-attempts.d.ts +5 -0
- package/dist/auth/sign-in-attempts.d.ts.map +1 -1
- package/dist/auth/types/change-email.d.ts +31 -0
- package/dist/auth/types/change-email.d.ts.map +1 -1
- package/dist/auth/types/index.d.ts +1 -1
- package/dist/auth/types/index.d.ts.map +1 -1
- package/dist/auth/types/janus.d.ts +9 -2
- package/dist/auth/types/janus.d.ts.map +1 -1
- package/dist/auth/types/password.d.ts +8 -2
- package/dist/auth/types/password.d.ts.map +1 -1
- package/dist/auth/users/change-password.d.ts +13 -0
- package/dist/auth/users/change-password.d.ts.map +1 -1
- package/dist/index.js +470 -450
- package/dist/index.js.map +20 -19
- package/docs/README.md +1 -1
- package/docs/guide/email-flows.md +14 -0
- package/docs/guide/passwords.md +8 -2
- package/docs/guide/users.md +3 -3
- package/docs/roadmap.md +11 -27
- package/docs/troubleshooting.md +15 -12
- 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`
|
|
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
|
|
package/docs/guide/passwords.md
CHANGED
|
@@ -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.
|
package/docs/guide/users.md
CHANGED
|
@@ -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
|
|
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.
|
package/docs/troubleshooting.md
CHANGED
|
@@ -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 `
|
|
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
|
|
742
|
-
current 15-minute window
|
|
743
|
-
included**, and whether or not anybody holds
|
|
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
|
|
748
|
-
never ends: advance the clock
|
|
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:
|
|
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.
|
|
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' } });
|