@nxgt/janus 0.6.0 → 0.8.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 +107 -17
- package/dist/auth/config.d.ts +8 -0
- package/dist/auth/config.d.ts.map +1 -1
- package/dist/auth/context.d.ts +10 -1
- package/dist/auth/context.d.ts.map +1 -1
- package/dist/auth/email-flows.d.ts +11 -0
- package/dist/auth/email-flows.d.ts.map +1 -0
- package/dist/auth/events.d.ts +52 -0
- package/dist/auth/events.d.ts.map +1 -0
- package/dist/auth/index.d.ts +1 -0
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/janus.d.ts.map +1 -1
- package/dist/auth/one-time.d.ts +7 -0
- package/dist/auth/one-time.d.ts.map +1 -1
- package/dist/auth/password-written.d.ts +24 -0
- package/dist/auth/password-written.d.ts.map +1 -0
- package/dist/auth/port/assert-stores.d.ts.map +1 -1
- package/dist/auth/port/types.d.ts +23 -1
- package/dist/auth/port/types.d.ts.map +1 -1
- package/dist/auth/second-factor/challenge.d.ts.map +1 -1
- package/dist/auth/sign-in-code.d.ts +6 -3
- package/dist/auth/sign-in-code.d.ts.map +1 -1
- package/dist/auth/types.d.ts +6 -0
- package/dist/auth/types.d.ts.map +1 -1
- package/dist/auth/users.d.ts.map +1 -1
- package/dist/chunks/{index-gbwn2tts.js → index-c0jajeda.js} +12 -2
- package/dist/chunks/{index-gbwn2tts.js.map → index-c0jajeda.js.map} +3 -3
- package/dist/conformance/cases/outage.d.ts.map +1 -1
- package/dist/conformance/cases/tokens.d.ts.map +1 -1
- package/dist/conformance/index.js +73 -2
- package/dist/conformance/index.js.map +4 -4
- package/dist/index.js +207 -95
- package/dist/index.js.map +14 -11
- package/docs/README.md +2 -1
- package/docs/guide/adapters.md +56 -3
- package/docs/guide/email-flows.md +11 -1
- package/docs/guide/events.md +199 -0
- package/docs/guide/second-factor.md +27 -2
- package/docs/guide/sign-in-code.md +28 -9
- package/docs/guide/users.md +8 -4
- package/docs/guide/vocabulary.md +3 -0
- package/docs/roadmap.md +25 -13
- package/docs/troubleshooting.md +89 -26
- package/package.json +1 -1
package/docs/README.md
CHANGED
|
@@ -16,6 +16,7 @@ below are defined once, in [Words](guide/vocabulary.md#words).
|
|
|
16
16
|
| [E-mail verification and password reset](guide/email-flows.md) | You are sending a verification or reset link, and handling what comes back |
|
|
17
17
|
| [Signing in with an e-mailed code](guide/sign-in-code.md) | You are signing users in with a six-digit code sent by e-mail — with no password, or beside one: requesting it without telling who exists, keeping the challenge, the attempts and the errors |
|
|
18
18
|
| [The second factor](guide/second-factor.md) | You are turning on TOTP codes: making and rotating the sealing keys, the QR code, `signIn`'s `status`, confirming a challenge, disabling |
|
|
19
|
+
| [User events](guide/events.md) | You want to hear when a user is created, verifies their e-mail, resets their password or is deleted — to queue it, sync it, or send it as a webhook |
|
|
19
20
|
| [Password hashing](guide/passwords.md) | You are choosing a hasher, raising its cost, moving to argon2id, or importing hashes from another system |
|
|
20
21
|
|
|
21
22
|
### Permissions — `@nxgt/janus/permissions`
|
|
@@ -30,6 +31,6 @@ below are defined once, in [Words](guide/vocabulary.md#words).
|
|
|
30
31
|
| --- | --- |
|
|
31
32
|
| [The shared vocabulary](guide/vocabulary.md) | You want the words the documentation uses, or subjects and the tuple notation, ids, cursors, durations or `fixedClock` |
|
|
32
33
|
| [Errors](guide/errors.md) | You are turning what the package throws into a status code, and want every code and what it carries |
|
|
33
|
-
| [Writing an adapter](guide/adapters.md) | You are implementing the identity stores or the relation store for your database, upgrading one for `countAttempt` and the second factor, or running the conformance suites |
|
|
34
|
+
| [Writing an adapter](guide/adapters.md) | You are implementing the identity stores or the relation store for your database, upgrading one for `countAttempt`, `spendUserTokens` and the second factor, or running the conformance suites |
|
|
34
35
|
| [Troubleshooting](troubleshooting.md) | You have an error message and want its cause and its fix |
|
|
35
36
|
| [Roadmap](roadmap.md) | You want to know what is coming, what shipped, and what is deliberately not planned |
|
package/docs/guide/adapters.md
CHANGED
|
@@ -133,6 +133,7 @@ interface TokenStore {
|
|
|
133
133
|
insertToken(record: TokenRecord): Promise<void>;
|
|
134
134
|
consumeToken(tokenHash: string, kind: TokenKind, at: Date): Promise<TokenRecord | null>;
|
|
135
135
|
countAttempt(tokenHash: string, kind: TokenKind): Promise<TokenRecord | null>;
|
|
136
|
+
spendUserTokens(userId: Id, kind: TokenKind, at: Date, except?: string): Promise<number>; // the unspent ones, but except
|
|
136
137
|
deleteUserTokens(userId: Id): Promise<number>;
|
|
137
138
|
}
|
|
138
139
|
```
|
|
@@ -222,6 +223,50 @@ Lua script: `HINCRBY` only when `spentAt` is empty, then `HGETALL`. Wrap the
|
|
|
222
223
|
driver's error in `StoreFailure` as in [the six rules](#the-six-rules):
|
|
223
224
|
`countAttempt` has its own outage case.
|
|
224
225
|
|
|
226
|
+
### `TokenStore.spendUserTokens`
|
|
227
|
+
|
|
228
|
+
Spends every **unspent** token of one user and one `kind` at `at` — but the
|
|
229
|
+
one whose hash is `except`, when given — and answers how many it spent. The
|
|
230
|
+
core calls it right after issuing a sign-in code, with that code's hash as
|
|
231
|
+
`except`, so only the last code sent works; and after writing a password,
|
|
232
|
+
for the user's `secondFactor` challenges:
|
|
233
|
+
|
|
234
|
+
| The stored token | Written | Counted |
|
|
235
|
+
| --- | --- | --- |
|
|
236
|
+
| unspent, of this user and this `kind` | `spentAt: at` | yes |
|
|
237
|
+
| already spent | nothing — `spentAt` never changes once set | no |
|
|
238
|
+
| another `kind`, another user, or the one named by `except` | nothing | no |
|
|
239
|
+
| none at all | nothing | `0`, an absence — never a failure |
|
|
240
|
+
|
|
241
|
+
Each token is spent by a **conditional write**, as `consumeToken` spends one:
|
|
242
|
+
a token that a racing `consumeToken` spends at the same moment is counted by
|
|
243
|
+
exactly one of the two calls, never both. An expired token is spent all the
|
|
244
|
+
same, or not counted by a store that already dropped it.
|
|
245
|
+
|
|
246
|
+
In MongoDB, one `updateMany` through the `userId` index `deleteUserTokens`
|
|
247
|
+
already reads:
|
|
248
|
+
|
|
249
|
+
```ts
|
|
250
|
+
import type { TokenStore } from '@nxgt/janus';
|
|
251
|
+
|
|
252
|
+
export const spendUserTokens: TokenStore['spendUserTokens'] = async (userId, kind, at, except) => {
|
|
253
|
+
const result = await tokens.updateMany(
|
|
254
|
+
{ userId, kind, spentAt: null, ...(except === undefined ? {} : { _id: { $ne: except } }) },
|
|
255
|
+
{ $set: { spentAt: at } },
|
|
256
|
+
);
|
|
257
|
+
return result.modifiedCount;
|
|
258
|
+
};
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
In SQL, `update … set spent_at = $3 where user_id = $1 and kind = $2 and
|
|
262
|
+
spent_at is null returning token_hash`, with `and token_hash <> $4` added
|
|
263
|
+
only when `except` is given — bound to `NULL`, `<>` matches no row — answering the row count: PostgreSQL
|
|
264
|
+
re-checks `spent_at is null` on a row a racing redemption just committed. In
|
|
265
|
+
Redis, one Lua script over the user's set of tokens, `HSET spentAt` on each
|
|
266
|
+
of the right `kind` whose `spentAt` is empty, skipping the one named by
|
|
267
|
+
`except`. `spendUserTokens` has its own
|
|
268
|
+
outage case.
|
|
269
|
+
|
|
225
270
|
### `RelationStore`
|
|
226
271
|
|
|
227
272
|
```ts
|
|
@@ -257,7 +302,11 @@ Written on the port's types, and checked by the suites:
|
|
|
257
302
|
normalises logins before a store sees them, and never hands a store
|
|
258
303
|
`\u0000` or a lone surrogate; every other character comes back as written.
|
|
259
304
|
5. **Every method is atomic on its own.** The core opens no transaction; an
|
|
260
|
-
adapter may open one inside a method.
|
|
305
|
+
adapter may open one inside a method. And **a read sees every write that
|
|
306
|
+
completed before it** — never a secondary or a read replica: a sign-in
|
|
307
|
+
re-reads the user to see a password written while it ran, and a new
|
|
308
|
+
sign-in code spends the ones issued before it. No suite can check this
|
|
309
|
+
one; a `readPreference: 'secondaryPreferred'` breaks it silently.
|
|
261
310
|
6. **Schema management is not on the port.** Expose your own `sync`; the core
|
|
262
311
|
never calls it.
|
|
263
312
|
|
|
@@ -298,7 +347,7 @@ compile error naming the missing method.
|
|
|
298
347
|
|
|
299
348
|
| Suite | Cases | Harness opens |
|
|
300
349
|
| --- | --- | --- |
|
|
301
|
-
| `describeJanusStores({ name, harness, runner?, faults?, skip? })` |
|
|
350
|
+
| `describeJanusStores({ name, harness, runner?, faults?, skip? })` | 49: users, sessions, tokens, and one outage per method whose honest answer can be "nothing" — thirteen of them | `{ stores, faults?, close? }` |
|
|
302
351
|
| `describeRelationStores({ name, harness, runner?, faults?, skip? })` | 15: the relation store, and one outage per method | `{ store, faults?, close? }` |
|
|
303
352
|
|
|
304
353
|
`harness.open()` is called **once per case** and must answer fresh, empty
|
|
@@ -315,7 +364,7 @@ stores: a case that leaks into the next is the hardest failure to debug.
|
|
|
315
364
|
|
|
316
365
|
The suites import no test framework and no assertion library.
|
|
317
366
|
|
|
318
|
-
The second factor and
|
|
367
|
+
The second factor, attempts and a user's tokens spent have their own cases — skip one by its id
|
|
319
368
|
while you work on it, never to ship:
|
|
320
369
|
|
|
321
370
|
| Case | Checks |
|
|
@@ -327,6 +376,10 @@ while you work on it, never to ship:
|
|
|
327
376
|
| `tokens.challenge` | a second-factor challenge, whose `address` is `''`, kept, counted and spent like any token |
|
|
328
377
|
| `tokens.countAttemptSpent` | a spent token answered unchanged; another kind and an unknown hash answer `null` and count nothing |
|
|
329
378
|
| `outage.countAttempt` | a store that cannot answer rejects, never `null` |
|
|
379
|
+
| `tokens.spendUserTokens` | spends the unspent tokens of one user and kind at `at`, keeping their attempts, and counts them; a spent token keeps its `spentAt`; another kind and another user are untouched; `0` for none |
|
|
380
|
+
| `tokens.spendUserTokensExcept` | spares the token named by `except`, and spends the user's others of that kind |
|
|
381
|
+
| `tokens.spendUserTokensRace` | racing one `consumeToken`, ten times over: exactly one of the two spends the token |
|
|
382
|
+
| `outage.spendUserTokens` | a store that cannot answer rejects, never `0` |
|
|
330
383
|
|
|
331
384
|
### `faults`: prove the outage invariant
|
|
332
385
|
|
|
@@ -74,6 +74,11 @@ readonly verifyEmail: {
|
|
|
74
74
|
`send` issues a token for the user's **current** e-mail. `confirm` redeems it
|
|
75
75
|
and sets `emailVerified`. A token sent to an e-mail the user has since changed
|
|
76
76
|
is `TOKEN_STALE`: confirming it would verify an address nobody holds any more.
|
|
77
|
+
The address is checked again on the very record the write replaces, so an
|
|
78
|
+
e-mail changed while the link is being redeemed is `TOKEN_STALE` too, and
|
|
79
|
+
nothing is written.
|
|
80
|
+
A confirm that verifies the e-mail sends a [`user.emailVerified`
|
|
81
|
+
event](events.md); one for an e-mail already verified sends nothing.
|
|
77
82
|
Changing the e-mail with `update` sets `emailVerified` back to `false`.
|
|
78
83
|
|
|
79
84
|
## `resetPassword`
|
|
@@ -100,7 +105,11 @@ export async function forgotPassword(request: Request): Promise<Response> {
|
|
|
100
105
|
```
|
|
101
106
|
|
|
102
107
|
`confirm` sets the password, marks the e-mail verified — the link proved it —
|
|
103
|
-
and **signs the user out everywhere
|
|
108
|
+
and **signs the user out everywhere**: their sessions are revoked, and every
|
|
109
|
+
second-factor challenge still open is spent, so a sign-in started with the
|
|
110
|
+
old password cannot be finished. The e-mail is checked again on the record
|
|
111
|
+
written, as for `verifyEmail`. It sends a [`user.passwordReset` event](events.md),
|
|
112
|
+
then `user.emailVerified` when the link verified the e-mail. It opens no session: call `signIn` next
|
|
104
113
|
if that is your policy. A password refused for its length does not spend the
|
|
105
114
|
token, so the visitor can try again with the same link.
|
|
106
115
|
|
|
@@ -147,4 +156,5 @@ never the token, and no refusal's message contains it.
|
|
|
147
156
|
- [Users](users.md) — `email`, `update`, and the other per-type methods
|
|
148
157
|
- [Sign-in codes](sign-in-code.md) — the third flow that sends an e-mail: a code, not a link
|
|
149
158
|
- [Sessions](sessions.md) — `signOutEverywhere`, which `resetPassword.confirm` calls for you
|
|
159
|
+
- [User events](events.md) — `user.emailVerified` and `user.passwordReset`, which the confirms send
|
|
150
160
|
- [Errors](errors.md) — every code, and the status it deserves
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# User events
|
|
2
|
+
|
|
3
|
+
This page is for hearing what happens to a user once it is written: created,
|
|
4
|
+
e-mail verified, password reset, deleted. Another service can then follow
|
|
5
|
+
without polling. `janus` hands each event to one function you give it;
|
|
6
|
+
**delivering it is yours**, or `@nxgt/janus-webhooks`'s once it ships.
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
import { z } from 'zod';
|
|
10
|
+
import { createMemoryStores, janus, scryptHasher, type UserEvent } from '@nxgt/janus';
|
|
11
|
+
|
|
12
|
+
const received: UserEvent[] = [];
|
|
13
|
+
|
|
14
|
+
const auth = janus({
|
|
15
|
+
user: z.object({ email: z.email() }),
|
|
16
|
+
password: { login: 'email' },
|
|
17
|
+
store: createMemoryStores(),
|
|
18
|
+
hasher: scryptHasher(),
|
|
19
|
+
events(event) {
|
|
20
|
+
received.push(event);
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const { user } = await auth.signUp({ email: 'ada@example.com', password: 'correct horse' });
|
|
25
|
+
received[0];
|
|
26
|
+
// {
|
|
27
|
+
// id: '0199…', a UUIDv7 minted for this event
|
|
28
|
+
// type: 'user.created',
|
|
29
|
+
// occurredAt: Date, when the write landed — its createdAt here
|
|
30
|
+
// userId: user.id,
|
|
31
|
+
// userType: 'user',
|
|
32
|
+
// }
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The words — **user event**, **listener** — are defined in
|
|
36
|
+
[the vocabulary](vocabulary.md#identities).
|
|
37
|
+
|
|
38
|
+
## The four types
|
|
39
|
+
|
|
40
|
+
| `type` | Sent by | Not sent |
|
|
41
|
+
| --- | --- | --- |
|
|
42
|
+
| `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`) |
|
|
43
|
+
| `user.emailVerified` | `verifyEmail.confirm`; `resetPassword.confirm`, whose link proves the e-mail; `signInCode.confirm`, whose code does | for an e-mail already verified, or a refused confirm |
|
|
44
|
+
| `user.passwordReset` | `resetPassword.confirm` | for `setPassword` or `changePassword` — they are not resets |
|
|
45
|
+
| `user.deleted` | `delete`, when it deleted the user | for a replay that finds nobody, or an id of another user type |
|
|
46
|
+
|
|
47
|
+
A reset whose link verifies the e-mail sends both, `user.passwordReset`
|
|
48
|
+
first. Switch on `type`: the four are a closed set, and TypeScript refuses a
|
|
49
|
+
fifth.
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
function onUserEvent(event: UserEvent): void {
|
|
53
|
+
switch (event.type) {
|
|
54
|
+
case 'user.created':
|
|
55
|
+
return welcome(event.userId);
|
|
56
|
+
case 'user.emailVerified':
|
|
57
|
+
return unlockFeatures(event.userId);
|
|
58
|
+
case 'user.passwordReset':
|
|
59
|
+
return alertSecurityTeam(event.userId);
|
|
60
|
+
case 'user.deleted':
|
|
61
|
+
return forgetEverywhere(event.userId);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## What an event carries
|
|
67
|
+
|
|
68
|
+
**The user named by id, and nothing else.** No login, no e-mail, no field of
|
|
69
|
+
the schema, no password or hash, no session or one-time token. An event can
|
|
70
|
+
land in a queue, a log or another company's endpoint; whoever receives it
|
|
71
|
+
reads the rest from where it is kept — `auth.get(event.userId)` — if they
|
|
72
|
+
may. A user deleted since is `NOT_FOUND` there, which is the answer.
|
|
73
|
+
|
|
74
|
+
`id` is minted for the event, a UUIDv7 that sorts by time. Deliver on it:
|
|
75
|
+
a queue job id, a primary key, a webhook's `webhook-id` header. Two events
|
|
76
|
+
never share one.
|
|
77
|
+
|
|
78
|
+
## When the listener runs
|
|
79
|
+
|
|
80
|
+
After the write, **awaited**, before the flow answers. So a listener
|
|
81
|
+
that stores the event durably — a job queue, an outbox table — has done so
|
|
82
|
+
when `signUp` answers:
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
const auth = janus({
|
|
86
|
+
...config,
|
|
87
|
+
async events(event) {
|
|
88
|
+
await jobs.add(event.type, event, { jobId: event.id });
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Where the listener runs within a flow, and what an outage does to it:
|
|
94
|
+
|
|
95
|
+
| Flow | The listener runs | A store outage after the write |
|
|
96
|
+
| --- | --- | --- |
|
|
97
|
+
| `create` | after the insert — its last step | — |
|
|
98
|
+
| `signUp` | after the insert, **before** the session is opened | fails the call; the event is already sent |
|
|
99
|
+
| `verifyEmail.confirm` | after the write — the flow's last step | — |
|
|
100
|
+
| `signInCode.confirm` | after the write, **before** the session or the second-factor challenge is opened | fails the call; the event is already sent |
|
|
101
|
+
| `resetPassword.confirm` | **after** the sessions opened with the old password are revoked and the second-factor challenges left open are spent | fails the call; the events are sent all the same, from a `finally` |
|
|
102
|
+
| `delete` | **after** the user's sessions and one-time tokens are removed, and the relation tuples naming them when `relations` is wired | fails the call; the event is sent all the same, from a `finally` |
|
|
103
|
+
|
|
104
|
+
So a listener that takes its time never leaves an old session alive after a
|
|
105
|
+
reset, and one that checks permissions on `user.deleted` finds the tuples
|
|
106
|
+
gone. A retry could not send a lost event either way: `delete` then finds
|
|
107
|
+
nobody, and the reset link is spent.
|
|
108
|
+
|
|
109
|
+
`occurredAt` is the write's own time: the `createdAt` or `updatedAt` it
|
|
110
|
+
wrote, the time read just before the deletion — not when the listener ran.
|
|
111
|
+
|
|
112
|
+
It runs inline, so it costs every flow that sends an event: store the event
|
|
113
|
+
and return. Sending an HTTP request from the listener makes every sign-up as
|
|
114
|
+
slow as the slowest endpoint, and as fragile.
|
|
115
|
+
|
|
116
|
+
## When the listener fails
|
|
117
|
+
|
|
118
|
+
**It fails no flow.** The write happened: answering `signUp` with an error
|
|
119
|
+
would tell the visitor their account does not exist when it does, and a retry
|
|
120
|
+
would hit `LOGIN_TAKEN`. So a listener that throws, or rejects, is a warning:
|
|
121
|
+
|
|
122
|
+
```
|
|
123
|
+
(node:4242) [JANUS_EVENT_FAILED] Warning: janus: the events listener failed on user.created 0199… for user 0199…: Error
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
It names the event's type, its `id` and the user's id — what it takes to send
|
|
127
|
+
it again — and the failure's name, never its message, which may hold anything.
|
|
128
|
+
Listen for it where you watch your process's health:
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
process.on('warning', (warning) => {
|
|
132
|
+
if ((warning as { code?: string }).code === 'JANUS_EVENT_FAILED') {
|
|
133
|
+
metrics.increment('janus.events.failed');
|
|
134
|
+
logger.error(warning.message);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## What is not promised
|
|
140
|
+
|
|
141
|
+
**At most once, from the process that wrote.** The event is handed to the
|
|
142
|
+
listener after the write, in memory: a crash between the two loses it, and
|
|
143
|
+
nothing sends it later — there is no outbox in the store. What must never
|
|
144
|
+
miss one — a billing sync, a search index — also reads the users now and
|
|
145
|
+
then, and treats events as the fast path.
|
|
146
|
+
|
|
147
|
+
**No order across processes.** Two instances each hand their own events to
|
|
148
|
+
their own listener. `occurredAt` and the time-sorted `id` let a receiver put
|
|
149
|
+
them back in order.
|
|
150
|
+
|
|
151
|
+
## In a test
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
import { expect, it } from 'bun:test';
|
|
155
|
+
import { z } from 'zod';
|
|
156
|
+
import { createMemoryStores, janus, scryptHasher, type UserEvent } from '@nxgt/janus';
|
|
157
|
+
|
|
158
|
+
it('tells the CRM about a sign-up', async () => {
|
|
159
|
+
const received: UserEvent[] = [];
|
|
160
|
+
const auth = janus({
|
|
161
|
+
user: z.object({ email: z.email() }),
|
|
162
|
+
password: { login: 'email' },
|
|
163
|
+
store: createMemoryStores(),
|
|
164
|
+
hasher: scryptHasher({ cost: 10 }),
|
|
165
|
+
events: (event) => void received.push(event),
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const { user } = await auth.signUp({ email: 'ada@example.com', password: 'correct horse' });
|
|
169
|
+
|
|
170
|
+
expect(received).toEqual([expect.objectContaining({ type: 'user.created', userId: user.id })]);
|
|
171
|
+
});
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## Signatures
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
type UserEventType = 'user.created' | 'user.emailVerified' | 'user.passwordReset' | 'user.deleted';
|
|
178
|
+
|
|
179
|
+
interface UserEvent {
|
|
180
|
+
readonly id: Id; // a UUIDv7, the key to deliver it once
|
|
181
|
+
readonly type: UserEventType;
|
|
182
|
+
readonly occurredAt: Date; // when the write landed: the createdAt or updatedAt written, or the time read just before a deletion
|
|
183
|
+
readonly userId: Id;
|
|
184
|
+
readonly userType: string;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
type UserEventListener = (event: UserEvent) => void | Promise<void>;
|
|
188
|
+
|
|
189
|
+
janus({ ..., events?: UserEventListener });
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
`events` that is not a function is a `TypeError` when `janus()` is called —
|
|
193
|
+
see [troubleshooting](../troubleshooting.md#janus-events-must-be-a-function-that-takes-a-user-event--webhooks---from-nxgtjanus-webhooks-or-your-own).
|
|
194
|
+
|
|
195
|
+
## See also
|
|
196
|
+
|
|
197
|
+
- [E-mail verification and password reset](email-flows.md) — the flows that send `user.emailVerified` and `user.passwordReset`
|
|
198
|
+
- [Users](users.md) — `create`, `signUp`, `delete`
|
|
199
|
+
- [Troubleshooting](../troubleshooting.md) — `JANUS_EVENT_FAILED`
|
|
@@ -203,6 +203,7 @@ interface SecondFactorRequired {
|
|
|
203
203
|
readonly status: 'secondFactor';
|
|
204
204
|
readonly challenge: string; // a secret, like a session token
|
|
205
205
|
readonly expiresAt: Date; // five minutes from now, by default
|
|
206
|
+
readonly userId: Id; // for your logs and rate limits — not for the visitor
|
|
206
207
|
}
|
|
207
208
|
```
|
|
208
209
|
|
|
@@ -261,8 +262,8 @@ const signedIn = await auth.secondFactor.confirm(challenge, code);
|
|
|
261
262
|
| Rejects with | When | What to do |
|
|
262
263
|
| --- | --- | --- |
|
|
263
264
|
| `CODE_INVALID`, with `attemptsLeft` | the code does not match, is not six digits, or was already accepted | ask again while `attemptsLeft > 0`; at `0` the challenge is spent: sign in again |
|
|
264
|
-
| `TOKEN_UNKNOWN` | no such challenge — a typo, another user type's, or one a store's TTL already dropped | sign in again |
|
|
265
|
-
| `TOKEN_SPENT` | the challenge already opened a session,
|
|
265
|
+
| `TOKEN_UNKNOWN` | no such challenge — a typo, another user type's, or one a store's TTL already dropped. Another type's challenge still loses one of its attempts, and its fifth spends it | sign in again |
|
|
266
|
+
| `TOKEN_SPENT` | the challenge already opened a session, its attempts ran out, or a password reset spent it | sign in again |
|
|
266
267
|
| `TOKEN_EXPIRED` | `expiresAt` has passed | sign in again |
|
|
267
268
|
| `USER_INACTIVE` | the user was deactivated since `signIn`. The challenge is spent | answer 403, as `signIn` would |
|
|
268
269
|
| `SECOND_FACTOR_NOT_ENROLLED` | the factor was disabled since `signIn`. The challenge is spent | sign in again: the password alone now opens a session |
|
|
@@ -292,6 +293,30 @@ Five attempts at a million values is a one-in-200,000 chance per password
|
|
|
292
293
|
guessed right. A new challenge takes a new sign-in, with the password, so the
|
|
293
294
|
attempts are bounded by your sign-in rate limit too.
|
|
294
295
|
|
|
296
|
+
A call made through **another user type's** API — `auth.staff.secondFactor.confirm`
|
|
297
|
+
for a patient's challenge — answers `TOKEN_UNKNOWN` and compares nothing, but
|
|
298
|
+
its attempt counts all the same: the fifth spends the challenge, as a wrong
|
|
299
|
+
code would.
|
|
300
|
+
|
|
301
|
+
**Writing a password ends the sign-ins left waiting.** `resetPassword.confirm`,
|
|
302
|
+
`setPassword` and `changePassword` spend every challenge of the user still
|
|
303
|
+
open, so whoever had the old password cannot finish a sign-in they started
|
|
304
|
+
with it:
|
|
305
|
+
|
|
306
|
+
```ts
|
|
307
|
+
const result = await auth.signIn({ email, password: oldPassword }); // a challenge
|
|
308
|
+
await auth.resetPassword.confirm(resetToken, newPassword);
|
|
309
|
+
await auth.secondFactor.confirm(result.challenge, code); // TOKEN_SPENT
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
A sign-in still running when the password is written is refused too.
|
|
313
|
+
`signIn` reads the user again once it answered: if the password it verified
|
|
314
|
+
is no longer theirs, it spends its own challenge — or revokes the session it
|
|
315
|
+
opened — and throws `CREDENTIALS_INVALID`. The writer spends after writing,
|
|
316
|
+
the sign-in reads after issuing, so however the two interleave one of them
|
|
317
|
+
sees the other. A hash rewritten for the same password — another sign-in
|
|
318
|
+
rehashing it — is not a change.
|
|
319
|
+
|
|
295
320
|
### Lifetime
|
|
296
321
|
|
|
297
322
|
A challenge lives `'5m'` unless `secondFactor.challenge` says otherwise, and
|
|
@@ -181,10 +181,10 @@ hash, like a session token, so the store cannot give it back either.
|
|
|
181
181
|
|
|
182
182
|
A challenge belongs to the user type that issued it: confirm a patient's
|
|
183
183
|
challenge with `clinic.patient.signInCode.confirm`. Another type's `confirm`
|
|
184
|
-
answers `TOKEN_UNKNOWN
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
184
|
+
answers `TOKEN_UNKNOWN` and compares no code — but it has already cost one of
|
|
185
|
+
the challenge's five attempts, because the attempt is counted before the type
|
|
186
|
+
is known. The challenge is left for its own type, with one attempt fewer, and
|
|
187
|
+
the fifth such call spends it, as a fifth wrong code would.
|
|
188
188
|
|
|
189
189
|
## Confirming the code
|
|
190
190
|
|
|
@@ -221,8 +221,26 @@ try {
|
|
|
221
221
|
Five attempts at a million values is a one-in-200,000 chance per challenge.
|
|
222
222
|
Codes sent at once past the fifth attempt are all refused, the right one
|
|
223
223
|
included: the store counts, and nothing reads the count before writing it.
|
|
224
|
-
|
|
225
|
-
|
|
224
|
+
|
|
225
|
+
**At most one code is live per user.** A `request` issues its code, then
|
|
226
|
+
spends every other challenge of the user, so the code in an earlier e-mail
|
|
227
|
+
answers `TOKEN_SPENT` — only the last one works:
|
|
228
|
+
|
|
229
|
+
```ts
|
|
230
|
+
const first = await auth.signInCode.request(email);
|
|
231
|
+
const second = await auth.signInCode.request(email); // the visitor asked again
|
|
232
|
+
await auth.signInCode.confirm(first.challenge, first.code); // TOKEN_SPENT
|
|
233
|
+
await auth.signInCode.confirm(second.challenge, second.code); // signed in
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Requests that arrive at once cannot each keep a code: each spends the others'
|
|
237
|
+
once it issued its own, so at most one survives — sometimes none, and the
|
|
238
|
+
visitor asks again. Guesses therefore never run against two live challenges.
|
|
239
|
+
|
|
240
|
+
**Rate-limit `request` per address.** A new challenge takes only a new
|
|
241
|
+
`request`, and each one sends an e-mail and cancels the code before it:
|
|
242
|
+
without a limit, anyone who knows an address can fill its inbox, or keep
|
|
243
|
+
its owner from ever typing a code in time.
|
|
226
244
|
|
|
227
245
|
### Lifetime
|
|
228
246
|
|
|
@@ -238,7 +256,8 @@ janus({ ..., tokens: { signInCode: '15m' } });
|
|
|
238
256
|
|
|
239
257
|
A user whose `emailVerified` was `false` has it `true` once `confirm`
|
|
240
258
|
succeeds, and their `version` moves: the code proves the address as a
|
|
241
|
-
verification link would
|
|
259
|
+
verification link would, and a [`user.emailVerified` event](events.md) is
|
|
260
|
+
sent. A user already verified is not written, and nothing is sent.
|
|
242
261
|
|
|
243
262
|
### A second factor is still asked for
|
|
244
263
|
|
|
@@ -270,8 +289,8 @@ answers `SignedIn`.
|
|
|
270
289
|
| Rejects with | When | What to do |
|
|
271
290
|
| --- | --- | --- |
|
|
272
291
|
| `CODE_INVALID`, with `attemptsLeft` | the code does not match, or is not six digits. Message: `signInCode.confirm: the code does not match, or was already used` | ask again while `attemptsLeft > 0`; at `0` the challenge is spent: request a new code |
|
|
273
|
-
| `TOKEN_UNKNOWN` | no such challenge — a typo, another user type's, one whose user was deleted, or one a store's TTL already dropped. Another type's challenge still loses one of its attempts | request a new code |
|
|
274
|
-
| `TOKEN_SPENT` | the challenge already signed someone in,
|
|
292
|
+
| `TOKEN_UNKNOWN` | no such challenge — a typo, another user type's, one whose user was deleted, or one a store's TTL already dropped. Another type's challenge still loses one of its attempts, and its fifth spends it | request a new code |
|
|
293
|
+
| `TOKEN_SPENT` | the challenge already signed someone in, its attempts ran out, or a newer `request` for the same user spent it — only the last code sent works | request a new code, and use the latest e-mail |
|
|
275
294
|
| `TOKEN_EXPIRED` | `expiresAt` has passed | request a new code |
|
|
276
295
|
| `TOKEN_STALE` | the user changed their e-mail since the code was sent. Message: `signInCode.confirm: the code was sent to an e-mail the user no longer has`. The challenge is spent | request a new code, to the current address |
|
|
277
296
|
| `USER_INACTIVE` | the user was deactivated since the code was sent. The challenge is spent | answer 403 |
|
package/docs/guide/users.md
CHANGED
|
@@ -96,6 +96,7 @@ that cuts an emoji in half, say.
|
|
|
96
96
|
| `tokens.resetPassword` | `Duration` | `'1h'` | How long a reset token lives |
|
|
97
97
|
| `tokens.signInCode` | `Duration` | `'10m'` | How long an e-mailed sign-in code and its challenge live. See [sign-in codes](sign-in-code.md) |
|
|
98
98
|
| `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) |
|
|
99
|
+
| `events` | `UserEventListener` | none | Called with every user event — `user.created`, `user.deleted`, … — after the write, awaited. See [user events](events.md) |
|
|
99
100
|
|
|
100
101
|
A `Duration` is `'500ms'`, `'30s'`, `'15m'`, `'8h'`, `'7d'`, or a number of
|
|
101
102
|
milliseconds.
|
|
@@ -185,10 +186,10 @@ With a `password`, besides:
|
|
|
185
186
|
| Method | Answers | Rejects with |
|
|
186
187
|
| --- | --- | --- |
|
|
187
188
|
| `signUp(fields & { password })` | `{ status: 'signedIn', user, session, token }` | `USER_INVALID`, `PASSWORD_TOO_SHORT`, `LOGIN_TAKEN` |
|
|
188
|
-
| `signIn({ [login]: string, password })` | `{ status: 'signedIn', user, session, token }` — or, with `secondFactor` configured and the user's factor active, `{ status: 'secondFactor', challenge, expiresAt }`: switch on `status` | `CREDENTIALS_INVALID`, `USER_INACTIVE`, `HASH_UNSUPPORTED` |
|
|
189
|
+
| `signIn({ [login]: string, password })` | `{ status: 'signedIn', user, session, token }` — or, with `secondFactor` configured and the user's factor active, `{ status: 'secondFactor', challenge, expiresAt, userId }`: switch on `status` | `CREDENTIALS_INVALID`, `USER_INACTIVE`, `HASH_UNSUPPORTED` |
|
|
189
190
|
| `findByLogin(login)` | the user, or `null`; the login is normalised first, and one holding a NUL or a lone surrogate is nobody's | |
|
|
190
|
-
| `setPassword(user, password, { ifVersion? })` | the user — an admin's call | `PASSWORD_TOO_SHORT` |
|
|
191
|
-
| `changePassword(user, { current, next }, { ifVersion? })` | the user — the user's own call | `CREDENTIALS_INVALID`, `PASSWORD_TOO_SHORT` |
|
|
191
|
+
| `setPassword(user, password, { ifVersion? })` | the user — an admin's call; spends the user's second-factor challenges still waiting, and signs nobody out | `PASSWORD_TOO_SHORT` |
|
|
192
|
+
| `changePassword(user, { current, next }, { ifVersion? })` | the user — the user's own call; spends the user's second-factor challenges still waiting | `CREDENTIALS_INVALID`, `PASSWORD_TOO_SHORT` |
|
|
192
193
|
|
|
193
194
|
With `secondFactor` configured, a type with a password also answers
|
|
194
195
|
`secondFactor.enroll`, `activate`, `disable` and `confirm` — see
|
|
@@ -228,7 +229,9 @@ read before that sign-in conflicts — see [passwords](passwords.md#rehash-on-si
|
|
|
228
229
|
Deletes the user **with every session and one-time token they had**, and
|
|
229
230
|
every tuple naming them when `relations` is wired. The user goes first, so an
|
|
230
231
|
outage half-way leaves only sessions and tokens that authenticate nobody. It is
|
|
231
|
-
idempotent: calling it again finishes the job.
|
|
232
|
+
idempotent: calling it again finishes the job. When it deleted the user, it
|
|
233
|
+
sends a [`user.deleted` event](events.md) — once, after the sessions, tokens and relation tuples are gone, and even when an outage interrupts removing them;
|
|
234
|
+
`create` and `signUp` send `user.created`.
|
|
232
235
|
|
|
233
236
|
### Paging every user
|
|
234
237
|
|
|
@@ -285,5 +288,6 @@ fields against your schema, not that `password` is a string.
|
|
|
285
288
|
## See also
|
|
286
289
|
|
|
287
290
|
- [Sessions](sessions.md) — `authenticate`, the cookie, signing out
|
|
291
|
+
- [User events](events.md) — what `create`, `signUp` and `delete` send
|
|
288
292
|
- [Errors](errors.md) — every code, and the status it deserves
|
|
289
293
|
- [Writing an adapter](adapters.md) — the identity stores behind `store`
|
package/docs/guide/vocabulary.md
CHANGED
|
@@ -51,10 +51,13 @@ either finds this row.
|
|
|
51
51
|
| **second factor** | What a user proves at sign-in beyond their password: a TOTP secret their authenticator app holds, `UserRecord.secondFactor`. **Enrolled** once `enroll` stored it, waiting for a first code (`confirmedAt: null`); **active** once `activate` accepted one — `hasSecondFactor`, and only then does `signIn` ask for a code | "2FA", "MFA", "OTP device"; "confirmed" for the state — to *confirm* is to redeem a challenge |
|
|
52
52
|
| **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 |
|
|
53
53
|
| **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`; `signInCode.request` answers one beside the sign-in code, for `signInCode.confirm`. Stored as its hash, as a one-time token of kind `secondFactor` or `signInCode`: five minutes or ten by default, and five attempts | "MFA token", "pending session", "ticket" |
|
|
54
|
+
| **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` for the same user (at most one sign-in code is live at a time), or a password written by `resetPassword.confirm`, `setPassword` or `changePassword` (every second-factor challenge left waiting) | "used up", "invalidated"; "consumed" only in `consumeToken`'s name; "revoked" is a session's |
|
|
54
55
|
| **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 |
|
|
55
56
|
| **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" |
|
|
56
57
|
| **token** | Never alone in prose: a *session token* or a *one-time token*. The `tokens` store and the `TOKEN_*` codes are one-time tokens only | |
|
|
57
58
|
| **e-mail flow** | `verifyEmail` or `resetPassword`: send a one-time token, then confirm it. `signInCode` sends a one-time code instead | |
|
|
59
|
+
| **user event** | What happened to a user, once it is written: `user.created`, `user.emailVerified`, `user.passwordReset`, `user.deleted` — a `UserEvent`, naming the user by id alone, with an `id` of its own. See [user events](events.md) | "webhook" — a webhook is one way to deliver it; "event" alone where it could be read as `@nxgt/janus-telemetry`'s audit log record — on a page about user events, "the event" is fine |
|
|
60
|
+
| **listener** | The one function `janus({ events })` hands every user event to, after the write and awaited | "handler", "hook", "subscriber" |
|
|
58
61
|
|
|
59
62
|
### Permissions
|
|
60
63
|
|
package/docs/roadmap.md
CHANGED
|
@@ -36,12 +36,13 @@ Nothing between releases.
|
|
|
36
36
|
catalogue, or replace any one template with your own function of the same
|
|
37
37
|
shape — built with the same toolkit, React Email or a plain string — and
|
|
38
38
|
keep the defaults for the rest.
|
|
39
|
-
- **Webhooks** —
|
|
40
|
-
(
|
|
41
|
-
follow without polling
|
|
42
|
-
the
|
|
43
|
-
|
|
44
|
-
|
|
39
|
+
- **Webhooks** — in a package of its own, `@nxgt/janus-webhooks`: the user
|
|
40
|
+
events `janus({ events })` already hands over, signed and sent over HTTP,
|
|
41
|
+
so another service can follow without polling. A signature it can check —
|
|
42
|
+
the Standard Webhooks headers, HMAC-SHA256, secrets that rotate — retries
|
|
43
|
+
with backoff on failure, and retries that run out reported to a function
|
|
44
|
+
you give, never dropped in silence. The payload is the event: the user
|
|
45
|
+
named by id, every key camelCase.
|
|
45
46
|
|
|
46
47
|
## Later
|
|
47
48
|
|
|
@@ -93,6 +94,24 @@ Nothing between releases.
|
|
|
93
94
|
The last ten, newest first, each with the version it came in. Everything
|
|
94
95
|
before is in the [CHANGELOG](../CHANGELOG.md).
|
|
95
96
|
|
|
97
|
+
- **User events, v0.8.0** — `janus({ events })` takes one listener, called
|
|
98
|
+
with `user.created`, `user.emailVerified`, `user.passwordReset` and
|
|
99
|
+
`user.deleted` once the write landed, and awaited before the flow answers.
|
|
100
|
+
An event names the user by id alone, with a UUIDv7 of its own to deliver
|
|
101
|
+
it once; a listener that throws fails no flow and is a
|
|
102
|
+
`JANUS_EVENT_FAILED` warning.
|
|
103
|
+
- **At most one sign-in code live per user, and challenges that end when
|
|
104
|
+
they should, v0.7.0** — `signInCode.request` spends the codes sent before,
|
|
105
|
+
even when requests race, so only the last e-mail's works; writing a
|
|
106
|
+
password spends the second-factor challenges left waiting, and a sign-in
|
|
107
|
+
still running when it lands is refused; another user type's `confirm` spends a challenge
|
|
108
|
+
at its fifth attempt; `verifyEmail.confirm` and `resetPassword.confirm`
|
|
109
|
+
check the e-mail again on the record they write. `SecondFactorRequired`
|
|
110
|
+
carries `userId`, for logs and rate limits. For adapters:
|
|
111
|
+
`TokenStore.spendUserTokens(userId, kind, at, except?)`, with four new conformance
|
|
112
|
+
cases, implemented in `@nxgt/janus-drizzle`, `@nxgt/janus-mongo` and
|
|
113
|
+
`@nxgt/janus-redis` — and the port now says a read sees every write that
|
|
114
|
+
completed before it: never a secondary or a read replica.
|
|
96
115
|
- **Sign in with a code sent by e-mail, v0.6.0** —
|
|
97
116
|
`auth.<type>.signInCode.request(email)` answers a six-digit code to send
|
|
98
117
|
and a challenge to keep with the visitor, or `null` for nobody — never
|
|
@@ -146,10 +165,3 @@ before is in the [CHANGELOG](../CHANGELOG.md).
|
|
|
146
165
|
- **`LOGIN_TAKEN` no longer quotes the login in its message**, in the memory
|
|
147
166
|
store and in both adapters; `error.login` still names it, and the
|
|
148
167
|
conformance suite checks it. — v0.2.0
|
|
149
|
-
- **The conformance suite accepts a store with its own expiry** — a store
|
|
150
|
-
that drops a lapsed session at once, as a Redis TTL does, passes
|
|
151
|
-
`sessions.deleteUser`; one that still holds it must count it. — v0.2.0
|
|
152
|
-
- **A Hono integration** — [`@nxgt/janus-hono`](https://www.npmjs.com/package/@nxgt/janus-hono):
|
|
153
|
-
the session middleware, the cookie, a route guarded by a permission,
|
|
154
|
-
`bindJanus()` to bind the instances once, and every error as its status.
|
|
155
|
-
Its own 0.1.0, beside `@nxgt/janus` 0.1.3.
|