@nxgt/janus 0.22.0 → 0.23.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 +97 -17
- package/dist/auth/config/janus-config.d.ts +2 -0
- package/dist/auth/config/janus-config.d.ts.map +1 -1
- package/dist/auth/config/resolved-config.d.ts +1 -0
- package/dist/auth/config/resolved-config.d.ts.map +1 -1
- package/dist/auth/config/warn-throttle-window.d.ts.map +1 -1
- package/dist/auth/email-flows/change-email-token.d.ts +42 -0
- package/dist/auth/email-flows/change-email-token.d.ts.map +1 -0
- package/dist/auth/email-flows/change-email.d.ts +11 -0
- package/dist/auth/email-flows/change-email.d.ts.map +1 -0
- package/dist/auth/email-flows/flows.d.ts +5 -5
- package/dist/auth/email-flows/flows.d.ts.map +1 -1
- package/dist/auth/events.d.ts +3 -2
- package/dist/auth/events.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/mail-requests.d.ts +6 -3
- package/dist/auth/mail-requests.d.ts.map +1 -1
- package/dist/auth/one-time/issue.d.ts +7 -1
- package/dist/auth/one-time/issue.d.ts.map +1 -1
- package/dist/auth/port/types/tokens.d.ts +10 -4
- package/dist/auth/port/types/tokens.d.ts.map +1 -1
- package/dist/auth/types/change-email.d.ts +51 -0
- package/dist/auth/types/change-email.d.ts.map +1 -0
- package/dist/auth/types/index.d.ts +1 -0
- package/dist/auth/types/index.d.ts.map +1 -1
- package/dist/auth/types/janus.d.ts +2 -1
- package/dist/auth/types/janus.d.ts.map +1 -1
- package/dist/auth/types/user-type.d.ts +3 -1
- package/dist/auth/types/user-type.d.ts.map +1 -1
- package/dist/auth/users/any-type-api.d.ts +2 -2
- package/dist/auth/users/any-type-api.d.ts.map +1 -1
- package/dist/auth/users/update.d.ts +2 -1
- package/dist/auth/users/update.d.ts.map +1 -1
- package/dist/conformance/cases/tokens/change-email.d.ts +8 -0
- package/dist/conformance/cases/tokens/change-email.d.ts.map +1 -0
- package/dist/conformance/cases/tokens/index.d.ts.map +1 -1
- package/dist/conformance/cases/tokens/kinds.d.ts.map +1 -1
- package/dist/conformance/index.js +92 -57
- package/dist/conformance/index.js.map +6 -5
- package/dist/errors/janus-error/codes.d.ts +7 -3
- package/dist/errors/janus-error/codes.d.ts.map +1 -1
- package/dist/index.js +134 -31
- package/dist/index.js.map +13 -11
- package/docs/README.md +1 -1
- package/docs/guide/adapters.md +13 -7
- package/docs/guide/email-flows.md +90 -10
- package/docs/guide/errors.md +3 -3
- package/docs/guide/events.md +5 -4
- package/docs/guide/users.md +10 -8
- package/docs/guide/vocabulary.md +3 -3
- package/docs/roadmap.md +13 -10
- package/docs/troubleshooting.md +32 -12
- package/package.json +3 -2
package/docs/README.md
CHANGED
|
@@ -13,7 +13,7 @@ below are defined once, in [Words](guide/vocabulary.md#words).
|
|
|
13
13
|
| --- | --- |
|
|
14
14
|
| [Users](guide/users.md) | You are wiring `janus()`, declaring one or several user types, or calling `create`, `update`, `list`, `delete` — or finding a user by e-mail on a type with no password (`findByEmail`) |
|
|
15
15
|
| [Sessions](guide/sessions.md) | You need to know who a request belongs to, set or clear the cookie, renew, sign out, or test expiry — or sign in a user your application proved itself, such as on accepting an invitation (`signInProven`) |
|
|
16
|
-
| [E-mail verification and
|
|
16
|
+
| [E-mail verification, password reset and changing an e-mail](guide/email-flows.md) | You are sending a verification, reset or e-mail-change link (`changeEmail`), and handling what comes back — or proving an address on your own proof, such as an invitation (`verifyEmail.proveFirst`) — and how requests that send e-mail are throttled per address or user |
|
|
17
17
|
| [Signing in with an e-mailed link](guide/magic-link.md) | You are signing users in with a link sent by e-mail — a magic link: the page that confirms it from a `POST` so mail scanners spend nothing, the lifetime, the errors, and what ends a link |
|
|
18
18
|
| [Signing in with an e-mailed code](guide/sign-in-code.md) | You are signing users in with a six-digit code sent by e-mail — with no password, or beside one: requesting it without telling who exists, keeping the challenge, the attempts and the errors |
|
|
19
19
|
| [The second factor](guide/second-factor.md) | You are turning on TOTP codes: making and rotating the sealing keys, the QR code, `signIn`'s `status`, confirming a challenge, recovery codes for a lost phone, disabling |
|
package/docs/guide/adapters.md
CHANGED
|
@@ -196,14 +196,14 @@ update. A read followed by a write lets two requests redeem one reset token.
|
|
|
196
196
|
A token is its hash, never its secret, and what it is for:
|
|
197
197
|
|
|
198
198
|
```ts
|
|
199
|
-
type TokenKind = 'verifyEmail' | 'resetPassword' | 'secondFactor' | 'signInCode' | 'magicLink' | 'stepUp';
|
|
199
|
+
type TokenKind = 'verifyEmail' | 'resetPassword' | 'secondFactor' | 'signInCode' | 'magicLink' | 'stepUp' | 'changeEmail';
|
|
200
200
|
|
|
201
201
|
interface TokenRecord {
|
|
202
202
|
readonly tokenHash: string;
|
|
203
203
|
readonly kind: TokenKind;
|
|
204
204
|
readonly userId: Id;
|
|
205
|
-
readonly address: string; // '' for a secondFactor challenge: nothing was sent
|
|
206
|
-
readonly codeHash: string | null; // an e-mailed code, hashed — signInCode, or stepUp by e-mail; else null
|
|
205
|
+
readonly address: string; // '' for a secondFactor challenge: nothing was sent; the NEW address for changeEmail
|
|
206
|
+
readonly codeHash: string | null; // an e-mailed code, hashed — signInCode, or stepUp by e-mail; changeEmail: the address it replaces, keyed; else null
|
|
207
207
|
readonly attempts: number; // 0 at insertion
|
|
208
208
|
readonly expiresAt: Date;
|
|
209
209
|
readonly spentAt: Date | null;
|
|
@@ -218,9 +218,15 @@ confirms an action, nor an action's code signs anyone in. A `magicLink`
|
|
|
218
218
|
token is a sign-in link's, new in 0.15, kept apart from `signInCode` the same
|
|
219
219
|
way: a sign-in code's challenge is handed to whoever asked for it, and a
|
|
220
220
|
store that redeemed it as a link would sign them in with no code —
|
|
221
|
-
`tokens.magicLinkKind` holds that it does not. A
|
|
222
|
-
|
|
223
|
-
|
|
221
|
+
`tokens.magicLinkKind` holds that it does not. A `changeEmail` token is the
|
|
222
|
+
link sent to a user's new address; its `address` is that new address, not the
|
|
223
|
+
one the user holds, and its `codeHash` is the address it replaces, keyed by the
|
|
224
|
+
token's secret — a store keeps both byte for byte. It is kept apart from
|
|
225
|
+
`verifyEmail` the same way: redeemed as a verification it would mark the new
|
|
226
|
+
address verified without changing it, and `tokens.changeEmailKind` holds that
|
|
227
|
+
it is not. A store that lists the kinds — a `CHECK`, an enum in a validator —
|
|
228
|
+
lists `stepUp`, `magicLink` and `changeEmail` too, which `tokens.everyKind`
|
|
229
|
+
proves. A `secondFactor` token is the challenge `signIn`
|
|
224
230
|
answers — or the count of a user's attempts at `regenerateRecoveryCodes`,
|
|
225
231
|
a token of the same kind whose secret nobody is given, never redeemed and
|
|
226
232
|
only counted — and its `address` is `''`: a column or a validator that refuses an
|
|
@@ -443,7 +449,7 @@ compile error naming the missing method.
|
|
|
443
449
|
|
|
444
450
|
| Suite | Cases | Harness opens |
|
|
445
451
|
| --- | --- | --- |
|
|
446
|
-
| `describeJanusStores({ name, harness, runner?, faults?, skip? })` |
|
|
452
|
+
| `describeJanusStores({ name, harness, runner?, faults?, skip? })` | 59: users, sessions, tokens, and one outage per method whose honest answer can be "nothing" — fifteen of them | `{ stores, faults?, close? }` |
|
|
447
453
|
| `describeRelationStores({ name, harness, runner?, faults?, skip? })` | 16: the relation store, ids of edge characters, and one outage per method | `{ store, faults?, close? }` |
|
|
448
454
|
|
|
449
455
|
`harness.open()` is called **once per case** and must answer fresh, empty
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
# E-mail verification and
|
|
1
|
+
# E-mail verification, password reset and changing an e-mail
|
|
2
2
|
|
|
3
|
-
This page is for the
|
|
4
|
-
e-mail,
|
|
5
|
-
tokens; **sending the e-mail is yours**.
|
|
3
|
+
This page is for the one-time-token flows: proving a user holds their
|
|
4
|
+
e-mail, resetting a forgotten password, and moving a user to a new e-mail.
|
|
5
|
+
`janus` issues and redeems the tokens; **sending the e-mail is yours**.
|
|
6
6
|
|
|
7
7
|
```ts
|
|
8
8
|
import { z } from 'zod';
|
|
@@ -40,7 +40,12 @@ of a password, an e-mail or a second factor changed, and a welcome on
|
|
|
40
40
|
Every link is required but two: `getStarted`, since janus-mail 0.4.0, is
|
|
41
41
|
where the welcome's **Get started** button leads; `recoveryCodes` and
|
|
42
42
|
`magicLink` — the page a [sign-in link](magic-link.md) opens, since
|
|
43
|
-
janus-mail 0.7.0 — are optional.
|
|
43
|
+
janus-mail 0.7.0 — are optional. **`changeEmail.send` is not among them
|
|
44
|
+
yet**: janus-mail has no template for the link to a new address. Send that
|
|
45
|
+
link with your own mailer; janus-mail sends the notice to the former address
|
|
46
|
+
(`emailChanged`). `mail.verifyEmail(issued)` is not the change-of-e-mail
|
|
47
|
+
mail: it would ask the new address to verify, not say the account moves
|
|
48
|
+
there.
|
|
44
49
|
|
|
45
50
|
```ts
|
|
46
51
|
import { janusMail } from '@nxgt/janus-mail';
|
|
@@ -104,6 +109,7 @@ with or without a password — see [sign-in codes](sign-in-code.md) and
|
|
|
104
109
|
| `email` | a field name | `'email'` | The field the tokens are sent to, per type |
|
|
105
110
|
| `tokens.verifyEmail` | `Duration` | `'24h'` | How long a verification token lives |
|
|
106
111
|
| `tokens.resetPassword` | `Duration` | `'1h'` | How long a reset token lives |
|
|
112
|
+
| `tokens.changeEmail` | `Duration` | `'1h'` | How long the link sent to a new address lives |
|
|
107
113
|
|
|
108
114
|
## `verifyEmail`
|
|
109
115
|
|
|
@@ -189,6 +195,79 @@ malformed id, an unknown one or one of another type is `NOT_FOUND`, as for
|
|
|
189
195
|
and writes `janus.email.verified` with `janus.email.proveFirst: true` when
|
|
190
196
|
it proved — the user's id, never the address.
|
|
191
197
|
|
|
198
|
+
## `changeEmail`
|
|
199
|
+
|
|
200
|
+
```ts
|
|
201
|
+
readonly changeEmail: {
|
|
202
|
+
send(user: UserRef, email: string): Promise<IssuedToken>; // { token, email, expiresAt } — email is the NEW address
|
|
203
|
+
confirm(token: string): Promise<User>;
|
|
204
|
+
};
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
`send` issues a token for the **new** address and changes nothing. The
|
|
208
|
+
address is validated as `update` validates it (`USER_INVALID`), the user must
|
|
209
|
+
be active (`USER_INACTIVE`), and an address equal to the user's current one
|
|
210
|
+
once normalised is `USER_INVALID` too — a change moves to another address;
|
|
211
|
+
verifying the current one is `verifyEmail`'s job. The call is throttled **per
|
|
212
|
+
new address and per user**, five per window each, before anything is issued;
|
|
213
|
+
a refusal carries the user's id. A `USER_INVALID` or a `USER_INACTIVE`
|
|
214
|
+
spends nothing. The user is counted first, so an account past its own count
|
|
215
|
+
spends no address's and can exhaust at most one address per window; an
|
|
216
|
+
address other people exhausted costs the asking user one of their own. The user's
|
|
217
|
+
earlier links are spent: only the last one works. Whether another user holds
|
|
218
|
+
the address is not checked, so `send` tells nobody who has an account.
|
|
219
|
+
|
|
220
|
+
`confirm` spends the token, moves the user to the address and marks it
|
|
221
|
+
verified (an address already verified keeps its date). The password, the
|
|
222
|
+
second factor and the sessions are untouched — the whole account moves with
|
|
223
|
+
the user, so the mail should say "ignore it if it was not you". If
|
|
224
|
+
the address was taken meanwhile, the store answers `LOGIN_TAKEN` and nothing
|
|
225
|
+
is written — the token is already spent. After an `update` that moves the
|
|
226
|
+
e-mail, the pending links are `TOKEN_SPENT`: `update` spends them, so a move
|
|
227
|
+
back to the old address revives none. `TOKEN_STALE` is a link whose e-mail
|
|
228
|
+
is no longer the one it was asked from although nothing spent it: a write
|
|
229
|
+
outside janus, or a `send` racing an `update`. `confirm` sends `user.emailChanged` (with
|
|
230
|
+
`formerEmail`) and `user.emailVerified`.
|
|
231
|
+
|
|
232
|
+
**Gate `send`, and tell the former address.** Whoever changes the e-mail
|
|
233
|
+
takes over every reset: call `send` behind a fresh sign-in or a step-up.
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
import { assertFresh, janus } from '@nxgt/janus';
|
|
237
|
+
|
|
238
|
+
declare function sendMail(to: string, body: string): Promise<void>; // your mailer
|
|
239
|
+
|
|
240
|
+
const auth = janus({
|
|
241
|
+
// …
|
|
242
|
+
// the former address hears of it
|
|
243
|
+
events: async (event) => {
|
|
244
|
+
if (event.type === 'user.emailChanged' && event.formerEmail != null) {
|
|
245
|
+
await sendMail(event.formerEmail, 'Your e-mail was changed.');
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
app.post('/email/change', async (c) => {
|
|
251
|
+
const current = await auth.authenticate(c.req.raw);
|
|
252
|
+
if (current === null) return c.body(null, 401);
|
|
253
|
+
assertFresh(current.session, '5m'); // STEP_UP_REQUIRED past five minutes — or a step-up
|
|
254
|
+
const { email } = await c.req.json<{ email: string }>();
|
|
255
|
+
const issued = await auth.changeEmail.send(current.user, email);
|
|
256
|
+
await sendMail(issued.email, `https://app.example/email/confirm?token=${issued.token}`);
|
|
257
|
+
return c.body(null, 202);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// the confirm page POSTs the token back — never confirm on the GET
|
|
261
|
+
app.post('/email/confirm', async (c) => {
|
|
262
|
+
const { token } = await c.req.json<{ token: string }>();
|
|
263
|
+
await auth.changeEmail.confirm(token);
|
|
264
|
+
return c.body(null, 204);
|
|
265
|
+
});
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
The adapter's store must list the `changeEmail` token kind first:
|
|
269
|
+
`@nxgt/janus-drizzle` 0.8's migration, `@nxgt/janus-mongo` 0.9's sync.
|
|
270
|
+
|
|
192
271
|
## `resetPassword`
|
|
193
272
|
|
|
194
273
|
```ts
|
|
@@ -329,6 +408,7 @@ try {
|
|
|
329
408
|
| `magicLink.request(email)` | address | Anybody can call it with any address. Counted before the address is looked up, so an address nobody holds is refused like a registered one |
|
|
330
409
|
| `signInCode.request(email)` | address | The same |
|
|
331
410
|
| `resetPassword.request(email)` | address | The same |
|
|
411
|
+
| `changeEmail.send(user, email)` | user, and new address | Counted twice (the user, then the new address), five per window each, before anything is issued — a refusal carries the user's id: a loop on one user cannot fill an inbox, and one address cannot be flooded from many users |
|
|
332
412
|
| `verifyEmail.send(user)` | user | The application calls it for a user it knows: an unauthenticated loop on the address cannot spend this count, so it never blocks the user's own verification, and a user changing e-mail buys no extra sends |
|
|
333
413
|
| `stepUp.request(user)`, when it e-mails a code (`via: 'email'`) | user | The same. A step-up confirmed with the app (`via: 'secondFactor'`) sends nothing and is not counted |
|
|
334
414
|
|
|
@@ -534,8 +614,8 @@ no longer yours to make. For an address nobody holds, `send()` answers
|
|
|
534
614
|
issues is checked against it. See
|
|
535
615
|
[the sign-in code guide](sign-in-code.md#requesting-a-code-in-two-steps).
|
|
536
616
|
|
|
537
|
-
**`verifyEmail.send` and `stepUp.request` have no
|
|
538
|
-
counted per user, and called for a user the application already holds — the
|
|
617
|
+
**`verifyEmail.send`, `changeEmail.send` and `stepUp.request` have no
|
|
618
|
+
`prepare`.** They are counted per user, and called for a user the application already holds — the
|
|
539
619
|
session's, or one it just created — so their time tells nothing about who
|
|
540
620
|
has an account, and their `MAIL_THROTTLED` already reaches the visitor in
|
|
541
621
|
the request that asked.
|
|
@@ -551,9 +631,9 @@ as by `request`.
|
|
|
551
631
|
| Code | When |
|
|
552
632
|
| --- | --- |
|
|
553
633
|
| `TOKEN_UNKNOWN` | No token holds that secret — or it was issued for the other flow: a verification token is not a reset token |
|
|
554
|
-
| `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 |
|
|
634
|
+
| `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; a change link, by a newer send or an update that moves the e-mail |
|
|
555
635
|
| `TOKEN_EXPIRED` | Its lifespan passed. It is spent all the same, so it cannot be retried |
|
|
556
|
-
| `TOKEN_STALE` |
|
|
636
|
+
| `TOKEN_STALE` | Asked for an e-mail the user no longer has: the link was sent to an address they have since left — or, for `changeEmail`, asked while they held an address they have since changed. Send a new link |
|
|
557
637
|
|
|
558
638
|
Of twenty concurrent redemptions of one token, exactly one succeeds: the store
|
|
559
639
|
spends it in one conditional write. The store holds the token's `sha256`,
|
|
@@ -565,5 +645,5 @@ never the token, and no refusal's message contains it.
|
|
|
565
645
|
- [Sign-in codes](sign-in-code.md) — the third flow that sends an e-mail: a code, not a link
|
|
566
646
|
- [Sign-in links](magic-link.md) — the fourth: a link that signs in, confirmed from a `POST` so a mail scanner spends nothing
|
|
567
647
|
- [Sessions](sessions.md) — `signOutEverywhere`, which `resetPassword.confirm` calls for you
|
|
568
|
-
- [User events](events.md) — `user.emailVerified` and `user.passwordReset`, which the confirms and `proveFirst` send
|
|
648
|
+
- [User events](events.md) — `user.emailVerified`, `user.emailChanged` and `user.passwordReset`, which the confirms and `proveFirst` send
|
|
569
649
|
- [Errors](errors.md) — every code, and the status it deserves
|
package/docs/guide/errors.md
CHANGED
|
@@ -50,14 +50,14 @@ that wired the library, so no handler needs to tell it apart.
|
|
|
50
50
|
| --- | --- | --- | --- | --- |
|
|
51
51
|
| `STORE_FAILED` | `StoreFailure` | 503 | A store could not answer. **Never a negative answer** | `slot`, `operation`, `cause` |
|
|
52
52
|
| `NOT_FOUND` | `NotFoundError` | 404 | `get`, `getUser`, `signInProven`, `verifyEmail.proveFirst`, or a write to a user who is gone. `find*` answers `null` instead | `userId` |
|
|
53
|
-
| `LOGIN_TAKEN` | `StoreConflict` (`on: 'login'`) | 409 | Another user of the same type holds the login | `login`, `userType` |
|
|
53
|
+
| `LOGIN_TAKEN` | `StoreConflict` (`on: 'login'`) | 409 | Another user of the same type holds the login — on `changeEmail.confirm`, the new address | `login`, `userType` |
|
|
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. From `verifyEmail.proveFirst`, the user was written between its read and its write: nothing was proved — call it again | `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
57
|
| `CREDENTIALS_INVALID` | `CredentialError` | 401 | Unknown login, no password, or the wrong one — **one code for the three** — and any password, the right one included, at a login past its attempts in the window — see [throttling](passwords.md#password-guessing-is-throttled) | `reason`, for your logs only; `retryAfter`, the seconds until the next window, when throttled |
|
|
58
58
|
| `HASH_UNSUPPORTED` | `CredentialError` | 400 | A stored hash no wired hasher reads | `hashPrefix` — never the hash |
|
|
59
|
-
| `MAIL_THROTTLED` | `MailThrottledError` | 429 | A request that sends e-mail was made too often for one address or one user in the window — see [the mail throttle](email-flows.md#requests-that-send-e-mail-are-throttled). **Nothing was issued, spent or rotated**: the last link or code sent still works | `retryAfter`, the whole seconds to the end of the window, at least 1; `userType`; `userId`
|
|
60
|
-
| `USER_INACTIVE` | `UserInactiveError` | 403 | Deactivated; told only to someone who gave the right password, the right code, or a live sign-in link — or to the application, by `signInProven` and `
|
|
59
|
+
| `MAIL_THROTTLED` | `MailThrottledError` | 429 | A request that sends e-mail (`changeEmail.send` included, per user and per new address) was made too often for one address or one user in the window — see [the mail throttle](email-flows.md#requests-that-send-e-mail-are-throttled). **Nothing was issued, spent or rotated**: the last link or code sent still works | `retryAfter`, the whole seconds to the end of the window, at least 1; `userType`; `userId` for the per-user flows, and for `changeEmail.send` whichever of its two counts refused. No `reason` |
|
|
60
|
+
| `USER_INACTIVE` | `UserInactiveError` | 403 | Deactivated; told only to someone who gave the right password, the right code, or a live sign-in link — or to the application, by `signInProven`, `verifyEmail.proveFirst` and `changeEmail.send` | `userId` |
|
|
61
61
|
| `STEP_UP_REQUIRED` | `StepUpRequiredError` | 403 | `assertFresh`: the session proved who it is `maxAge` ago or more. Not a denial of the action — ask for a [step-up](step-up.md), then send the request again | `userId` |
|
|
62
62
|
| `TOKEN_UNKNOWN`, `TOKEN_SPENT`, `TOKEN_EXPIRED`, `TOKEN_STALE` | `TokenError` | 400 | See [e-mail flows](email-flows.md#what-a-token-refusal-means). For a second factor's challenge: sign in again; for a sign-in code's: request a new code — see [sign-in codes](sign-in-code.md#what-confirm-refuses); for a sign-in link: ask for a new link — see [sign-in links](magic-link.md#what-confirm-refuses) | `userId` on `TOKEN_STALE` |
|
|
63
63
|
| `CODE_INVALID` | `TokenError` | 401 | A one-time code that does not match: a second factor's, or one already accepted — see [the second factor](second-factor.md#confirming-the-code-at-sign-in) — a recovery code not the user's, or already used — see [recovery codes](second-factor.md#signing-in-with-one) — or a sign-in code sent by e-mail — see [sign-in codes](sign-in-code.md#attempts) | `attemptsLeft` from either `confirm` and from `recover`: what the challenge has left, `0` once it is spent; from `regenerateRecoveryCodes`, what the user's 15-minute window has left, `0` past the fifth — see [regenerating them](second-factor.md#regenerating-them). None from `activate`. `userId` |
|
package/docs/guide/events.md
CHANGED
|
@@ -44,10 +44,10 @@ The words — **user event**, **listener** — are defined in
|
|
|
44
44
|
| `type` | Sent by | Not sent |
|
|
45
45
|
| --- | --- | --- |
|
|
46
46
|
| `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`) |
|
|
47
|
-
| `user.emailVerified` | `verifyEmail.confirm` and `verifyEmail.proveFirst`; `resetPassword.confirm` and `magicLink.confirm`, whose link proves the e-mail; `signInCode.confirm`, whose code does | for an e-mail already verified
|
|
47
|
+
| `user.emailVerified` | `verifyEmail.confirm` and `verifyEmail.proveFirst`; `changeEmail.confirm`, whose link reached the new address; `resetPassword.confirm` and `magicLink.confirm`, whose link proves the e-mail; `signInCode.confirm`, whose code does | for an e-mail already verified — `changeEmail.confirm` to the same address in another case included — or a refused confirm |
|
|
48
48
|
| `user.passwordReset` | `resetPassword.confirm` | for `setPassword` or `changePassword` — they are not resets |
|
|
49
49
|
| `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; `signInCode.confirm`, `magicLink.confirm` and `verifyEmail.proveFirst` when their first proof of the e-mail dropped a password, after `user.emailVerified` | for a reset, which is `user.passwordReset` alone; for a `changePassword` refused (`CREDENTIALS_INVALID`) |
|
|
50
|
-
| `user.emailChanged` | `update`, when
|
|
50
|
+
| `user.emailChanged` | `update` and `changeEmail.confirm`, when they 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 |
|
|
51
51
|
| `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 |
|
|
52
52
|
| `user.secondFactorDisabled` | `secondFactor.disable`, when it removed an **active** factor; `signInCode.confirm`, `magicLink.confirm` and `verifyEmail.proveFirst` when their first proof of the e-mail removed one, after `user.emailVerified` and any `user.passwordChanged` — even if an outage interrupts the sign-outs | for a user who had no factor, a factor still waiting for its first code — it was never asked for — or a second `disable` |
|
|
53
53
|
| `user.recoveryCodesRegenerated` | `secondFactor.regenerateRecoveryCodes`: the codes the user held stopped working | for `activate`, whose first codes come with `user.secondFactorEnabled`; for a code refused |
|
|
@@ -243,7 +243,8 @@ Where the listener runs within a flow, and what an outage does to it:
|
|
|
243
243
|
| `create` | after the insert — its last step | — |
|
|
244
244
|
| `signUp` | after the insert, **before** the session is opened | fails the call; the event is already sent |
|
|
245
245
|
| `verifyEmail.confirm` | after the write — the flow's last step | — |
|
|
246
|
-
| `update` | after the write — the
|
|
246
|
+
| `update` | after the write — and, when it moved the e-mail, after the user's pending change links are spent | fails the call with `STORE_FAILED`, the write landed; the event is sent all the same, from a `finally` |
|
|
247
|
+
| `changeEmail.confirm` | after the write — the flow's last step: `user.emailChanged`, then `user.emailVerified` | — (a `VERSION_CONFLICT` or `LOGIN_TAKEN` leaves nothing written and sends nothing; the token is already spent) |
|
|
247
248
|
| `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` |
|
|
248
249
|
| `signInCode.confirm`, `magicLink.confirm`, `verifyEmail.proveFirst` | on an e-mail never verified: **after** every session is revoked and the reset links and second-factor challenges left open are spent — before the write and again after it — and **before** the new session or second-factor challenge is opened. `user.passwordChanged` follows `user.emailVerified` when a password was dropped, then `user.secondFactorDisabled` when an active second factor was removed. An e-mail already verified sends nothing | fails the call; the events are sent all the same, from a `finally`. An outage in the sign-outs before the write fails the call with nothing written and nothing sent: the next code, link or `proveFirst` proves the e-mail again |
|
|
249
250
|
| `secondFactor.activate`, `secondFactor.regenerateRecoveryCodes`, `secondFactor.disable` | after the write — the flow's last step | — |
|
|
@@ -359,7 +360,7 @@ see [troubleshooting](../troubleshooting.md#janus-events-must-be-a-function-that
|
|
|
359
360
|
|
|
360
361
|
## See also
|
|
361
362
|
|
|
362
|
-
- [E-mail verification and
|
|
363
|
+
- [E-mail verification, password reset and changing an e-mail](email-flows.md) — the flows that send `user.emailVerified`, `user.passwordReset` and, with `changeEmail`, `user.emailChanged`
|
|
363
364
|
- [The second factor](second-factor.md) — `activate` and `disable`, which send `user.secondFactorEnabled` and `user.secondFactorDisabled` — sent too by the first proof of an e-mail by a [code](sign-in-code.md) or a [link](magic-link.md#an-account-someone-else-registered); [recovery codes](second-factor.md#recovery-codes), which send `user.recoveryCodesRegenerated` and `user.recoveryCodeUsed`
|
|
364
365
|
- [Devices](devices.md) — the sign-ins that send `user.newDeviceSignedIn`, the device token and the cookie
|
|
365
366
|
- [Users](users.md) — `create`, `signUp`, `update`, `delete`
|
package/docs/guide/users.md
CHANGED
|
@@ -98,7 +98,7 @@ that cuts an emoji in half, say.
|
|
|
98
98
|
| `tokens.magicLink` | `Duration` | `'15m'` | How long an e-mailed sign-in link lives. See [sign-in links](magic-link.md) |
|
|
99
99
|
| `tokens.stepUp` | `Duration` | `'10m'` | How long a step-up's challenge — and its e-mailed code — lives. See [step-up](step-up.md) |
|
|
100
100
|
| `signIn.throttle` | `{ attempts?, window? }` or `false` | `{ attempts: 10, window: '15m' }` | Passwords one login may try per window, the right one included, before `signIn` answers `CREDENTIALS_INVALID` with `retryAfter` until the window ends. `false` counts nothing. See [passwords](passwords.md#password-guessing-is-throttled) |
|
|
101
|
-
| `mail.throttle` | `{ attempts?, window? }` or `false` | `{ attempts: 5, window: '10m' }` | E-mails one address (`magicLink`, `signInCode`, `resetPassword` requests) or one user (`verifyEmail.send`, an e-mailed `stepUp.request`) may ask for per flow and window, before the request throws `MAIL_THROTTLED` with `retryAfter` and issues nothing (the last link or code sent still works). Keep `window` no longer than the shortest `tokens.*` lifetime you use. `false` counts nothing. See [e-mail flows](email-flows.md#requests-that-send-e-mail-are-throttled) |
|
|
101
|
+
| `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) |
|
|
104
104
|
| `events` | `UserEventListener` | none | Called with every user event — `user.created`, `user.deleted`, … — after the write, awaited. See [user events](events.md) |
|
|
@@ -183,7 +183,7 @@ In the one-type form these are on `auth` itself; with several types, on
|
|
|
183
183
|
| `findMany(ids)` | the users, in the order of `ids`, each once; an id `find` answers `null` for is left out — see [below](#several-users-at-once--findmany) | |
|
|
184
184
|
| `get(id)` | the user | `NOT_FOUND` |
|
|
185
185
|
| `list({ after?, limit? })` | `CursorPage<User>`, in creation order | `INVALID_CURSOR` |
|
|
186
|
-
| `update(user, patch, { ifVersion? })` | the user as written; sends `user.emailChanged` when the e-mail changed | `USER_INVALID`, `LOGIN_TAKEN`, `VERSION_CONFLICT`, `NOT_FOUND` |
|
|
186
|
+
| `update(user, patch, { ifVersion? })` | the user as written; sends `user.emailChanged` when the e-mail changed; to move a user to a proved address, use [`changeEmail`](email-flows.md#changeemail) | `USER_INVALID`, `LOGIN_TAKEN`, `VERSION_CONFLICT`, `NOT_FOUND` |
|
|
187
187
|
| `setActive(user, active, { ifVersion? })` | the user as written | `VERSION_CONFLICT`, `NOT_FOUND` |
|
|
188
188
|
| `delete(user)` | `true`, or `false` for an unknown id or one of another type | |
|
|
189
189
|
| `signInProven(user, { device? })` | **privileged** — after a proof your server verified, such as an invitation token: what `signIn` answers, a session or a second-factor challenge; no credential checked, nothing sent, counted or spent — see [sessions](sessions.md#signing-in-on-your-own-proof--signinproven) | `NOT_FOUND`, `USER_INACTIVE` |
|
|
@@ -208,12 +208,14 @@ With `secondFactor` configured, a type with a password also answers
|
|
|
208
208
|
`secondFactor.enroll`, `activate`, `disable` and `confirm` — see
|
|
209
209
|
[the second factor](second-factor.md).
|
|
210
210
|
|
|
211
|
-
Every method may also reject with `STORE_FAILED`. `
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
211
|
+
Every method may also reject with `STORE_FAILED`. `update` (when it moves
|
|
212
|
+
the e-mail: it spends the user's pending change links), `setPassword` and
|
|
213
|
+
`changePassword` (they spend the user's reset links and second-factor
|
|
214
|
+
challenges) do that after the write, so a `STORE_FAILED` there may come with
|
|
215
|
+
the write already landed — the e-mail moved, or the password changed: a
|
|
216
|
+
retried `changePassword` with the old `current` then answers
|
|
217
|
+
`CREDENTIALS_INVALID`. A `user` argument is a user or its id
|
|
218
|
+
(`UserRef = string | { id: string }`).
|
|
217
219
|
|
|
218
220
|
```ts
|
|
219
221
|
const grace = await auth.create({ email: 'grace@example.com', name: 'Grace', active: false });
|
package/docs/guide/vocabulary.md
CHANGED
|
@@ -53,13 +53,13 @@ either finds this row.
|
|
|
53
53
|
| **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" |
|
|
54
54
|
| **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 |
|
|
55
55
|
| **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" |
|
|
56
|
-
| **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`, `magicLink.request`, `stepUp.request` or `
|
|
56
|
+
| **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`, `magicLink.request`, `stepUp.request`, `resetPassword.request` or `changeEmail.send` for the same user (at most one of each is live at a time), an `update` that moves the e-mail (every change link), 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 |
|
|
57
57
|
| **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 |
|
|
58
58
|
| **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" |
|
|
59
59
|
| **device**, **device token** | A device is a browser or an app a user signs in from, as the client's long-lived cookie — or a bearer client's storage — tells it. A **device token** is what janus signs for it with `devices.keys`, answers as `deviceToken`, and takes back as `device`: `d1.<key id>.<device id>.<mac>`, bound to one user, stored nowhere. A sign-in from a **new device** — one whose token did not prove this user signed in there — answers `newDevice: true` and sends `user.newDeviceSignedIn`. See [devices](devices.md) | "fingerprint", "trusted device", "remembered browser"; "token" alone |
|
|
60
60
|
| **token** | Never alone in prose: a *session token*, a *one-time token* or a *device token*. The `tokens` store and the `TOKEN_*` codes are one-time tokens only | |
|
|
61
|
-
| **e-mail flow** | `verifyEmail` or `
|
|
62
|
-
| **issued** | What a flow answers for the application to send: an `IssuedToken` from `verifyEmail.send`, `resetPassword.request` or `magicLink.request`, an `IssuedCode` from `signInCode.request` — the address with it. `@nxgt/janus-mail` takes it as it is | "result", "payload" |
|
|
61
|
+
| **e-mail flow** | `verifyEmail`, `resetPassword` or `changeEmail`: send a one-time token, then confirm it. `magicLink` sends one too, to sign in; `signInCode` sends a one-time code instead | |
|
|
62
|
+
| **issued** | What a flow answers for the application to send: an `IssuedToken` from `verifyEmail.send`, `changeEmail.send`, `resetPassword.request` or `magicLink.request`, an `IssuedCode` from `signInCode.request` — the address with it. `@nxgt/janus-mail` takes it as it is, except `changeEmail.send`'s: it has no template for that link yet, so send it with your own mailer | "result", "payload" |
|
|
63
63
|
| **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 — *two-factor enabled*, *two-factor disabled*, *recovery code used* and *new sign-in*. `@nxgt/janus-mail` sends all six | "alert", "notification" |
|
|
64
64
|
| **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.newDeviceSignedIn`, `user.deleted` — a `UserEvent`, naming the user by id alone — `user.emailChanged` adds `formerEmail`, `user.newDeviceSignedIn` adds `sessionId` — 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 |
|
|
65
65
|
| **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 |
|
package/docs/roadmap.md
CHANGED
|
@@ -76,6 +76,19 @@ Nothing yet.
|
|
|
76
76
|
The last ten, newest first, each with the version it came in. Everything
|
|
77
77
|
before is in the [CHANGELOG](../CHANGELOG.md).
|
|
78
78
|
|
|
79
|
+
- **Changing a user's e-mail, v0.23.0** —
|
|
80
|
+
`auth.<type>.changeEmail.send(user, email)` issues a link for the new
|
|
81
|
+
address, and `changeEmail.confirm(token)` moves the user there, verified,
|
|
82
|
+
keeping the password, the second factor and the sessions. The address is
|
|
83
|
+
validated as `update` does and never checked for a holder at `send`;
|
|
84
|
+
`confirm` answers `LOGIN_TAKEN` with the token spent. Throttled per user
|
|
85
|
+
and per new address; a link lives an hour (`tokens.changeEmail`); a newer
|
|
86
|
+
`send` spends the earlier links, and so does an `update` that moves the
|
|
87
|
+
e-mail, so moving back revives none. `user.emailChanged` carries `formerEmail` so the
|
|
88
|
+
former address can be told. Gate `send` behind a fresh sign-in or a
|
|
89
|
+
step-up. **Breaking for an adapter: `TokenKind` gains `'changeEmail'`**,
|
|
90
|
+
which a `CHECK` or a validator's enum must list — the janus-drizzle 0.8
|
|
91
|
+
migration, the janus-mongo 0.9 sync.
|
|
79
92
|
- **Proving an e-mail on the application's own proof, v0.22.0** —
|
|
80
93
|
`auth.<type>.verifyEmail.proveFirst(user)` applies the first proof of an
|
|
81
94
|
e-mail that a sign-in link or code applies — verified, the password and
|
|
@@ -157,13 +170,3 @@ before is in the [CHANGELOG](../CHANGELOG.md).
|
|
|
157
170
|
from a `POST`, so a mail scanner that opens the link spends nothing.
|
|
158
171
|
**Breaking for an adapter: `TokenKind` gains `'magicLink'`**, which a
|
|
159
172
|
`CHECK` or a validator's enum must list.
|
|
160
|
-
- **Password guessing throttled per login, v0.14.0** — past ten passwords
|
|
161
|
-
tried at one login in a 15-minute window, `signIn` answers
|
|
162
|
-
`CREDENTIALS_INVALID` with `reason: 'throttled'` and `retryAfter`, the right
|
|
163
|
-
password included, until the window ends. Nothing locks; a login nobody
|
|
164
|
-
holds is counted alike; a password sign-in that opens a session —
|
|
165
|
-
after its second factor, when one is active — starts the count again.
|
|
166
|
-
On by default: `signIn: { throttle: { attempts, window } }` changes it,
|
|
167
|
-
`signIn: { throttle: false }` turns it off. A tokens store that cannot
|
|
168
|
-
count fails the sign-in with `STORE_FAILED`. No change for adapters: the
|
|
169
|
-
counts are `secondFactor` tokens, counted by `TokenStore.countAttempt`.
|
package/docs/troubleshooting.md
CHANGED
|
@@ -49,6 +49,7 @@ How the messages are shaped:
|
|
|
49
49
|
|
|
50
50
|
**Users, sessions and tokens**
|
|
51
51
|
- [`STORE_FAILED` — `<slot>.<method>: the store could not answer`](#store_failed--slotmethod-the-store-could-not-answer)
|
|
52
|
+
- [`STORE_FAILED` — `tokens.insertToken: the store could not answer`, on `changeEmail.send` alone](#store_failed--tokensinserttoken-the-store-could-not-answer-on-changeemailsend-alone)
|
|
52
53
|
- [`STORE_FAILED` — `<slot>.<method> answered undefined …`](#store_failed--slotmethod-answered-undefined-an-absence-is-null-so-this-store-forgot-to-answer)
|
|
53
54
|
- [`STORE_FAILED` — `users.findUsers answered no list …`](#store_failed--usersfindusers-answered-no-list-an-absence-is-a-shorter-list-so-this-store-forgot-to-answer)
|
|
54
55
|
- [`<call>: ids must be an array of user ids`](#call-ids-must-be-an-array-of-user-ids)
|
|
@@ -167,6 +168,7 @@ How the messages are shaped:
|
|
|
167
168
|
- [`reauthenticateSession racing revokeSession: the session should stay revoked — one conditional write, never a read then a write`](#reauthenticatesession-racing-revokesession-the-session-should-stay-revoked--one-conditional-write-never-a-read-then-a-write)
|
|
168
169
|
- [`tokens.insertToken: the store could not answer`, in `tokens.everyKind`](#tokensinserttoken-the-store-could-not-answer-in-tokenseverykind)
|
|
169
170
|
- [`consumeToken for a signInCode token redeemed as magicLink`, in `tokens.magicLinkKind`](#consumetoken-for-a-signincode-token-redeemed-as-magiclink-in-tokensmagiclinkkind)
|
|
171
|
+
- [`consumeToken for a verifyEmail token redeemed as changeEmail`, in `tokens.changeEmailKind`](#consumetoken-for-a-verifyemail-token-redeemed-as-changeemail-in-tokenschangeemailkind)
|
|
170
172
|
|
|
171
173
|
---
|
|
172
174
|
|
|
@@ -366,7 +368,7 @@ Also `janus: "<name>" cannot name a user type — janus() answers a method of th
|
|
|
366
368
|
|
|
367
369
|
### `janus: session.lifespan: "<value>" is not a duration; write a number followed by ms, s, m, h or d — for example "15m" or "720h"`
|
|
368
370
|
|
|
369
|
-
The same for `session.renewAfter`, `tokens.verifyEmail`, `tokens.resetPassword`, `tokens.signInCode`, `tokens.magicLink`, `tokens.stepUp`, `secondFactor.challenge` and `signIn.throttle.window` — and, at call time rather than in `janus()`, `assertFresh: maxAge: "<value>" is not a duration; …`, from a `maxAge` written wrong. Also `<option>: a duration must be above zero` and `<option>: a duration in milliseconds must be a finite number above zero`.
|
|
371
|
+
The same for `session.renewAfter`, `tokens.verifyEmail`, `tokens.resetPassword`, `tokens.signInCode`, `tokens.magicLink`, `tokens.stepUp`, `tokens.changeEmail`, `secondFactor.challenge` and `signIn.throttle.window` — and, at call time rather than in `janus()`, `assertFresh: maxAge: "<value>" is not a duration; …`, from a `maxAge` written wrong. Also `<option>: a duration must be above zero` and `<option>: a duration in milliseconds must be a finite number above zero`.
|
|
370
372
|
|
|
371
373
|
**When:** `janus({...})`.
|
|
372
374
|
**Why:** a duration is a number of milliseconds, or a number followed by one unit. `'30 m'` compiles — TypeScript's `${number}` accepts the space — and is refused here.
|
|
@@ -546,6 +548,14 @@ try {
|
|
|
546
548
|
}
|
|
547
549
|
```
|
|
548
550
|
|
|
551
|
+
### `STORE_FAILED` — `tokens.insertToken: the store could not answer`, on `changeEmail.send` alone
|
|
552
|
+
|
|
553
|
+
`StoreFailure`, whose `error.cause` reads `new row for relation "tokens" violates check constraint "tokens_kind"` with `@nxgt/janus-drizzle`, or `Document failed validation` with `@nxgt/janus-mongo`.
|
|
554
|
+
|
|
555
|
+
**When:** `changeEmail.send`, on a database not yet migrated for the `changeEmail` token kind; every other call works.
|
|
556
|
+
**Why:** `changeEmail` is a token kind of its own, and the adapter's table (drizzle's `tokens_kind` check) or collection validator (mongo's `kind` enum) does not list it yet.
|
|
557
|
+
**Fix:** `@nxgt/janus-drizzle` 0.8: apply its migration (`drizzle-kit migrate`). `@nxgt/janus-mongo` 0.9: run `syncMongoAdapter` once at start-up (`syncMongoStores` for an application that only authenticates, as the sync guide says). Until then answer 503, as for any outage.
|
|
558
|
+
|
|
549
559
|
### `STORE_FAILED` — `<slot>.<method> answered undefined: an absence is null, so this store forgot to answer`
|
|
550
560
|
|
|
551
561
|
**When:** with a store you wrote, on the first call to that method.
|
|
@@ -617,7 +627,8 @@ const user = await auth.find(id); // null when there is nobody
|
|
|
617
627
|
|
|
618
628
|
**When:** `signUp`, `create`, or an `update` that changes the login, when another user of **the same type** holds it after normalisation (`Ada@Example.com` and `ada@example.com` collide by default).
|
|
619
629
|
**Why:** the store's unique constraint refused the write. A login is unique per user type: one e-mail may hold a patient user and a staff user.
|
|
620
|
-
**
|
|
630
|
+
**On `changeEmail.confirm`** the address was free when `changeEmail.send` asked — `send` never checks — and somebody took it before the link was used. The link is already spent and nothing was written: the user keeps their e-mail.
|
|
631
|
+
**Fix:** answer 409. On `changeEmail.confirm`, tell the user the address is taken and ask for another with a new `changeEmail.send`. If two concurrent sign-ups with one login both succeed, the unique index is missing — with `@nxgt/janus-mongo`, run `syncMongoStores(db)`.
|
|
621
632
|
|
|
622
633
|
### `VERSION_CONFLICT` — `<call>: expected version <n>, found <m>`
|
|
623
634
|
|
|
@@ -683,8 +694,8 @@ does the first.
|
|
|
683
694
|
|
|
684
695
|
`UserInvalidError`, carrying `issues` — each a `path` and a `message`.
|
|
685
696
|
|
|
686
|
-
**When:** `signUp`, `create`, `update`.
|
|
687
|
-
**Why:** your schema refused the fields. `update` merges the patch over the stored fields and validates the whole, so the refusal can name a field the patch did not touch. An issue whose message is `set by janus, not by a request` means the input carried a field janus sets (`id`, `active`, `version`, …) and your schema let it through. An issue whose message is `holds a NUL character or a lone surrogate, which no store can keep` means a string — or, with `a key holds …`, an object key — carried `\u0000` or half of a surrogate pair: PostgreSQL refuses both, so janus refuses them on every adapter rather than answer `STORE_FAILED` on one. A key is reported by the path of the object holding it, never by the key itself — in janus's issues and in your schema's alike. An issue at your login field whose message is `normalises to a login that holds …` means your own `password.normalize` function produced such a login from a valid field: fix the function. On MongoDB or the memory store, a user written before janus refused these characters may already hold one: every `update` of that user is refused, whatever it patches, until the field is cleaned — patch it with a clean value in the same `update`.
|
|
697
|
+
**When:** `signUp`, `create`, `update`, and `changeEmail.send`.
|
|
698
|
+
**Why:** your schema refused the fields. `changeEmail.send` also answers it, with one issue at the e-mail field (`is the current e-mail of the user`), for an address equal to the user's current one once normalised: nothing is counted or issued; answer 400, or offer `verifyEmail.send` to prove the current one. `update` merges the patch over the stored fields and validates the whole, so the refusal can name a field the patch did not touch. An issue whose message is `set by janus, not by a request` means the input carried a field janus sets (`id`, `active`, `version`, …) and your schema let it through. An issue whose message is `holds a NUL character or a lone surrogate, which no store can keep` means a string — or, with `a key holds …`, an object key — carried `\u0000` or half of a surrogate pair: PostgreSQL refuses both, so janus refuses them on every adapter rather than answer `STORE_FAILED` on one. A key is reported by the path of the object holding it, never by the key itself — in janus's issues and in your schema's alike. An issue at your login field whose message is `normalises to a login that holds …` means your own `password.normalize` function produced such a login from a valid field: fix the function. On MongoDB or the memory store, a user written before janus refused these characters may already hold one: every `update` of that user is refused, whatever it patches, until the field is cleaned — patch it with a clean value in the same `update`.
|
|
688
699
|
**Fix:** answer 400, field by field:
|
|
689
700
|
|
|
690
701
|
```ts
|
|
@@ -747,7 +758,8 @@ janus({ ..., signIn: { throttle: { attempts: 20, window: '15m' } } });
|
|
|
747
758
|
it, or wait for the next window` and carry `userId`.
|
|
748
759
|
|
|
749
760
|
**When:** `magicLink.request`, `signInCode.request` or
|
|
750
|
-
`resetPassword.request` (per address), or `verifyEmail.send
|
|
761
|
+
`resetPassword.request` (per address), or `verifyEmail.send`,
|
|
762
|
+
`changeEmail.send` (per user, and per new address) and an e-mailed
|
|
751
763
|
`stepUp.request` (per user), past five requests of that flow in the current
|
|
752
764
|
10-minute window — whether or not anybody holds the address. `<call>` is the
|
|
753
765
|
call, such as `magicLink.request` or `patient.signInCode.request`.
|
|
@@ -917,7 +929,7 @@ janus({ ..., hasher: scryptHasher(), verifiers: [bcryptVerifier] }); // a Passwo
|
|
|
917
929
|
|
|
918
930
|
### `USER_INACTIVE` — `<call>: the user is inactive`
|
|
919
931
|
|
|
920
|
-
**When:** `signIn`, with the **right** password, for a user set inactive. Also `secondFactor.confirm` and `secondFactor.recover`, for a user set inactive after `signIn` asked for a code, `signInCode.confirm`, for a user set inactive after the code was sent, and `magicLink.confirm`, for one set inactive after the link was sent. And `signInProven` and `verifyEmail.proveFirst`, for an inactive user: the application's proof does not reactivate anybody, and proves nothing on an inactive one.
|
|
932
|
+
**When:** `signIn`, with the **right** password, for a user set inactive. Also `changeEmail.send` and `changeEmail.confirm` (the link is spent), and `secondFactor.confirm` and `secondFactor.recover`, for a user set inactive after `signIn` asked for a code, `signInCode.confirm`, for a user set inactive after the code was sent, and `magicLink.confirm`, for one set inactive after the link was sent. And `signInProven` and `verifyEmail.proveFirst`, for an inactive user: the application's proof does not reactivate anybody, and proves nothing on an inactive one.
|
|
921
933
|
**Why:** an inactive user keeps their record and password, and every sign-in is refused. It is checked after the password, so only somebody who knows the password learns the user is inactive — and after the code, so only somebody who read the e-mail does: `signInCode.request` answers `null` for an inactive user, as for nobody. On either `confirm`, the challenge is spent: reactivating the user does not revive it. `magicLink.confirm` checks after spending the link, which is spent too, and `magicLink.request` answers `null` for an inactive user.
|
|
922
934
|
**Fix:** answer 403, or reactivate, then sign in again: `await auth.setActive(user, true)`.
|
|
923
935
|
|
|
@@ -925,8 +937,8 @@ janus({ ..., hasher: scryptHasher(), verifiers: [bcryptVerifier] }); // a Passwo
|
|
|
925
937
|
|
|
926
938
|
`TokenError`: `<call>: no such token`, `<call>: the token was already used`, `<call>: the token has expired`.
|
|
927
939
|
|
|
928
|
-
**When:** `verifyEmail.confirm`, `resetPassword.confirm`.
|
|
929
|
-
**Why:** a token is spent by its first use, and an expired one is spent too. `TOKEN_UNKNOWN` also covers a token whose user was deleted. The defaults are 24 h for `verifyEmail`, 1 h for `resetPassword`.
|
|
940
|
+
**When:** `verifyEmail.confirm`, `resetPassword.confirm`, `changeEmail.confirm`.
|
|
941
|
+
**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.
|
|
930
942
|
A reset link answers `resetPassword.confirm: the token was already used` in
|
|
931
943
|
two more cases: a newer `resetPassword.request` for the same user spent it —
|
|
932
944
|
only the last link sent works, so a visitor who asked twice and clicked the
|
|
@@ -1014,9 +1026,9 @@ janus({ ..., tokens: { magicLink: '30m' } });
|
|
|
1014
1026
|
|
|
1015
1027
|
### `TOKEN_STALE` — `<call>: the token was sent to an e-mail the user no longer has`
|
|
1016
1028
|
|
|
1017
|
-
**When:** `verifyEmail.confirm`, `resetPassword.confirm` or `
|
|
1029
|
+
**When:** `verifyEmail.confirm`, `resetPassword.confirm`, `magicLink.confirm` or `changeEmail.confirm`, after the user changed their e-mail — even while the link was being redeemed: the address is checked again on the very record the write replaces. A change link is `TOKEN_SPENT`, not stale, after an `update` that moved the e-mail; it is stale after a write outside janus, or a `send` racing an `update`.
|
|
1018
1030
|
**Why:** confirming it would verify an address nobody holds any more, or reset a password through one. The token is spent, and nothing is written.
|
|
1019
|
-
**Fix:** send a new token to the current address: `await auth.verifyEmail.send(user)` — or, for a sign-in link, ask for a new one: `magicLink.request` sends it to the current address.
|
|
1031
|
+
**Fix:** send a new token to the current address: `await auth.verifyEmail.send(user)` — or, for a sign-in link, ask for a new one: `magicLink.request` sends it to the current address — or, for a change link, send a new one: `await auth.changeEmail.send(user, newEmail)`.
|
|
1020
1032
|
|
|
1021
1033
|
### `INVALID_CURSOR` — `<call>: this cursor was not minted by this store, or was minted for another ordering (<n> characters)`
|
|
1022
1034
|
|
|
@@ -2201,8 +2213,8 @@ In SQL, `update … set authenticated_at = $2 where id = $1 and revoked_at is nu
|
|
|
2201
2213
|
### `tokens.insertToken: the store could not answer`, in `tokens.everyKind`
|
|
2202
2214
|
|
|
2203
2215
|
**When:** the case `tokens.everyKind`, on an adapter whose database lists the token kinds — a `CHECK`, a validator's enum.
|
|
2204
|
-
**Why:** the list does not hold `stepUp`, which came in `@nxgt/janus` 0.12,
|
|
2205
|
-
**Fix:** add the missing kind to the list, and ship the migration or the validator with it: `'verifyEmail', 'resetPassword', 'secondFactor', 'signInCode', 'magicLink', 'stepUp'`.
|
|
2216
|
+
**Why:** the list does not hold `stepUp`, which came in `@nxgt/janus` 0.12, `magicLink`, which came in 0.15, or `changeEmail`, which came in 0.23, so the database refuses the insert, and the adapter reports it as the outage it cannot tell apart.
|
|
2217
|
+
**Fix:** add the missing kind to the list, and ship the migration or the validator with it: `'verifyEmail', 'resetPassword', 'secondFactor', 'signInCode', 'magicLink', 'stepUp', 'changeEmail'`.
|
|
2206
2218
|
|
|
2207
2219
|
### `consumeToken for a signInCode token redeemed as magicLink`, in `tokens.magicLinkKind`
|
|
2208
2220
|
|
|
@@ -2222,3 +2234,11 @@ await tokens.findOneAndUpdate(
|
|
|
2222
2234
|
```
|
|
2223
2235
|
|
|
2224
2236
|
In SQL, `… where token_hash = $1 and kind = $2`; `spendUserTokens` holds `user_id = $1 and kind = $2`. See [`TokenStore`](guide/adapters.md#sessionstore-and-tokenstore).
|
|
2237
|
+
|
|
2238
|
+
### `consumeToken for a verifyEmail token redeemed as changeEmail`, in `tokens.changeEmailKind`
|
|
2239
|
+
|
|
2240
|
+
Also `consumeToken for a changeEmail token redeemed as verifyEmail`, `countAttempt for a changeEmail token counted as verifyEmail`, `spendUserTokens of changeEmail: the change only`, and `spendUserTokens of changeEmail should not touch a verification, nor anything else have counted it`.
|
|
2241
|
+
|
|
2242
|
+
**When:** the case `tokens.changeEmailKind`, on an adapter that matches a token by its hash alone, or spends a user's tokens of every kind at once. The case stores a `changeEmail` and a `verifyEmail` token for one user and one address.
|
|
2243
|
+
**Why:** a verification link proves the address its user already holds; an e-mail change moves the user to the address its token names. Redeemed as one another, a link would verify an address it should have changed, or change one it should only have verified — the kind is what keeps the two apart, so every token method must match it.
|
|
2244
|
+
**Fix:** put `kind` in the condition of `consumeToken`, `countAttempt` and `spendUserTokens`, exactly as for `tokens.magicLinkKind` above. If the case instead fails with `expected …, got …` on the record, the adapter dropped `address` or `codeHash`: a `changeEmail` token keeps the new address in the first and the address it replaces, keyed, in the second.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nxgt/janus",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"description": "Embeddable, type-safe identities and permissions: bring your own database",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -62,9 +62,10 @@
|
|
|
62
62
|
},
|
|
63
63
|
"devDependencies": {
|
|
64
64
|
"@types/bun": "^1.4.2",
|
|
65
|
+
"typescript-6": "npm:typescript@~6.0.3",
|
|
65
66
|
"zod": "^4"
|
|
66
67
|
},
|
|
67
68
|
"peerDependencies": {
|
|
68
|
-
"typescript": "^6.0.3"
|
|
69
|
+
"typescript": "^6.0.3 || ^7.0.0"
|
|
69
70
|
}
|
|
70
71
|
}
|