@nxgt/janus 0.16.1 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +99 -11
  2. package/dist/auth/config/index.d.ts +1 -1
  3. package/dist/auth/config/index.d.ts.map +1 -1
  4. package/dist/auth/config/janus-config.d.ts +24 -3
  5. package/dist/auth/config/janus-config.d.ts.map +1 -1
  6. package/dist/auth/config/resolve-config.d.ts.map +1 -1
  7. package/dist/auth/config/resolve-devices.d.ts +5 -0
  8. package/dist/auth/config/resolve-devices.d.ts.map +1 -0
  9. package/dist/auth/config/resolved-config.d.ts +4 -0
  10. package/dist/auth/config/resolved-config.d.ts.map +1 -1
  11. package/dist/auth/devices/hint.d.ts +30 -0
  12. package/dist/auth/devices/hint.d.ts.map +1 -0
  13. package/dist/auth/devices/index.d.ts +10 -0
  14. package/dist/auth/devices/index.d.ts.map +1 -0
  15. package/dist/auth/devices/report.d.ts +11 -0
  16. package/dist/auth/devices/report.d.ts.map +1 -0
  17. package/dist/auth/devices/token.d.ts +13 -0
  18. package/dist/auth/devices/token.d.ts.map +1 -0
  19. package/dist/auth/email-sign-in.d.ts +4 -2
  20. package/dist/auth/email-sign-in.d.ts.map +1 -1
  21. package/dist/auth/events.d.ts +18 -5
  22. package/dist/auth/events.d.ts.map +1 -1
  23. package/dist/auth/index.d.ts +2 -2
  24. package/dist/auth/index.d.ts.map +1 -1
  25. package/dist/auth/magic-link.d.ts.map +1 -1
  26. package/dist/auth/second-factor/challenge.d.ts +2 -2
  27. package/dist/auth/second-factor/challenge.d.ts.map +1 -1
  28. package/dist/auth/second-factor/flows.d.ts +6 -4
  29. package/dist/auth/second-factor/flows.d.ts.map +1 -1
  30. package/dist/auth/second-factor/recovery.d.ts +6 -2
  31. package/dist/auth/second-factor/recovery.d.ts.map +1 -1
  32. package/dist/auth/sessions/open-session.d.ts +7 -1
  33. package/dist/auth/sessions/open-session.d.ts.map +1 -1
  34. package/dist/auth/sign-in-code.d.ts.map +1 -1
  35. package/dist/auth/types/email-flows.d.ts +2 -2
  36. package/dist/auth/types/email-flows.d.ts.map +1 -1
  37. package/dist/auth/types/index.d.ts +1 -1
  38. package/dist/auth/types/index.d.ts.map +1 -1
  39. package/dist/auth/types/magic-link.d.ts +2 -2
  40. package/dist/auth/types/magic-link.d.ts.map +1 -1
  41. package/dist/auth/types/password.d.ts +10 -3
  42. package/dist/auth/types/password.d.ts.map +1 -1
  43. package/dist/auth/types/second-factor.d.ts +6 -3
  44. package/dist/auth/types/second-factor.d.ts.map +1 -1
  45. package/dist/auth/types/sign-in.d.ts +32 -0
  46. package/dist/auth/types/sign-in.d.ts.map +1 -1
  47. package/dist/auth/users/flow-types.d.ts +2 -1
  48. package/dist/auth/users/flow-types.d.ts.map +1 -1
  49. package/dist/auth/users/password-flows.d.ts.map +1 -1
  50. package/dist/auth/users/sign-in.d.ts +5 -1
  51. package/dist/auth/users/sign-in.d.ts.map +1 -1
  52. package/dist/index.js +172 -73
  53. package/dist/index.js.map +19 -15
  54. package/docs/README.md +2 -1
  55. package/docs/guide/devices.md +429 -0
  56. package/docs/guide/events.md +53 -8
  57. package/docs/guide/magic-link.md +25 -3
  58. package/docs/guide/second-factor.md +30 -5
  59. package/docs/guide/sessions.md +8 -0
  60. package/docs/guide/sign-in-code.md +22 -3
  61. package/docs/guide/users.md +3 -2
  62. package/docs/guide/vocabulary.md +4 -3
  63. package/docs/roadmap.md +12 -8
  64. package/docs/troubleshooting.md +210 -4
  65. package/package.json +1 -1
package/docs/README.md CHANGED
@@ -18,7 +18,8 @@ below are defined once, in [Words](guide/vocabulary.md#words).
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 |
20
20
  | [Step-up](guide/step-up.md) | A sensitive action — changing the e-mail, disabling the second factor, deleting the account — should ask a signed-in user to prove again who they are: `assertFresh`, `stepUp.request` and `confirm`, by e-mailed code or by their app |
21
- | [User events](guide/events.md) | You want to hear when a user is created, verifies their e-mail, resets or changes their password, changes their e-mail, turns the second factor on or off, uses a recovery code or is deleted — to queue it, sync it, or send it as a webhook |
21
+ | [Devices](guide/devices.md) | You want to tell a user their account was signed in from a new device: wiring `devices`, the `device` each sign-in takes, the device cookie, the second factor, the notice by e-mail, rotating the keys and forgetting devices |
22
+ | [User events](guide/events.md) | You want to hear when a user is created, verifies their e-mail, resets or changes their password, changes their e-mail, turns the second factor on or off, uses a recovery code, signs in from a new device or is deleted — to queue it, sync it, or send it as a webhook |
22
23
  | [Password hashing](guide/passwords.md) | You are choosing a hasher, raising its cost, moving to argon2id, or importing hashes from another system — or how `signIn` throttles password guessing per login, and what you still limit per client |
23
24
 
24
25
  ### Permissions — `@nxgt/janus/permissions`
@@ -0,0 +1,429 @@
1
+ # Devices — a sign-in from a new device
2
+
3
+ This page is for telling a user when their account is signed in from a
4
+ device they had not signed in from: wiring `devices`, giving each sign-in the
5
+ **device token** the client holds, keeping it in a cookie, and sending the
6
+ notice.
7
+
8
+ ```ts
9
+ import { z } from 'zod';
10
+ import { createMemoryStores, janus, scryptHasher } from '@nxgt/janus';
11
+
12
+ const auth = janus({
13
+ user: z.object({ email: z.email() }),
14
+ password: { login: 'email' },
15
+ store: createMemoryStores(),
16
+ hasher: scryptHasher(),
17
+ devices: { keys: [{ id: '2026-09', key: process.env.DEVICES_KEY ?? '' }] }, // openssl rand -base64 32
18
+ });
19
+
20
+ const credentials = { email: 'ada@example.com', password: 'correct horse' };
21
+
22
+ const signedUp = await auth.signUp(credentials, { device: null }); // no token yet
23
+ signedUp.deviceToken; // 'd1.2026-09.…' — keep it in a long-lived cookie
24
+ signedUp.newDevice; // false: a sign-up is never new
25
+
26
+ const again = await auth.signIn(credentials, { device: signedUp.deviceToken });
27
+ again.newDevice; // false: the device Ada signed up on
28
+
29
+ const elsewhere = await auth.signIn(credentials, { device: null }); // a browser with no cookie
30
+ elsewhere.newDevice; // true — and the event user.newDeviceSignedIn was sent
31
+ elsewhere.deviceToken; // a fresh token, for that browser's cookie
32
+ ```
33
+
34
+ **Nothing is stored.** A device token is a random device id and a keyed hash
35
+ binding it to the user: the client keeps it, and it proves "this user signed
36
+ in on this device before" on its own. The store ports are unchanged, and no
37
+ adapter needs a migration. The words — **device**, **device token** — are
38
+ defined in [the vocabulary](vocabulary.md#identities).
39
+
40
+ ## Wiring
41
+
42
+ | Option | Type | Default | Effect |
43
+ | --- | --- | --- | --- |
44
+ | `devices.keys` | `[SealingKey, ...SealingKey[]]` | — required once `devices` is given | The keys device tokens are signed with. **The first signs, every one checks.** See [Rotating the keys](#rotating-the-keys-and-forgetting-devices) |
45
+
46
+ Without `devices`, no sign-in may be given a `device`: every answer's
47
+ `newDevice` is `false`, its `deviceToken` `null`, and no
48
+ `user.newDeviceSignedIn` is sent.
49
+
50
+ ```ts
51
+ interface DevicesConfig {
52
+ readonly keys: readonly [SealingKey, ...SealingKey[]];
53
+ }
54
+
55
+ interface SealingKey {
56
+ readonly id: string; // letters, digits, _ and -, at most 64: written into every token it signs
57
+ readonly key: string; // 32 random bytes, in base64 or base64url
58
+ }
59
+ ```
60
+
61
+ The keys have the format of [`secondFactor.keys`](second-factor.md#making-a-key),
62
+ and are made the same way:
63
+
64
+ ```sh
65
+ openssl rand -base64 32
66
+ ```
67
+
68
+ Use a key of its own rather than a sealing key of the second factor's: the
69
+ two rotate for different reasons. Keep it where your other secrets are, and
70
+ give **every `janus()` that signs users in the same `devices`** — a token
71
+ signed by an instance whose keys another does not hold is a new device there.
72
+
73
+ `janus()` refuses a configuration it cannot sign with, with a bare
74
+ `TypeError`:
75
+
76
+ | Message | Cause |
77
+ | --- | --- |
78
+ | `janus: devices.keys: expected at least one key — [{ id, key }], the first seals` | `keys` missing or empty |
79
+ | `janus: devices.keys: every key needs an id of letters, digits, _ and -, at most 64 of them` | an `id` with a `.`, a space, or over 64 characters |
80
+ | `janus: devices.keys: two keys have the id "<id>"` | a repeated `id` |
81
+ | `janus: devices.keys: the key "<id>" is not 32 bytes in base64 — make one with openssl rand -base64 32` | a key of another length, or not base64 — an environment variable that is not set, say |
82
+
83
+ `devices: { keys: [] }` is also a compile error.
84
+
85
+ ## Giving a sign-in the device
86
+
87
+ Every call that opens a session takes `{ device }` as its last argument, a
88
+ `SignInOptions`:
89
+
90
+ | Call | Given a device |
91
+ | --- | --- |
92
+ | `signUp(input, { device })` | mints the user's first token: `newDevice` is always `false`, and no event is sent — the device they signed up on is known from the start |
93
+ | `signIn(input, { device })` | answers `newDevice` and `deviceToken`; a new device sends `user.newDeviceSignedIn`. With an active second factor, the challenge — see [below](#with-a-second-factor) |
94
+ | `secondFactor.confirm(challenge, code, { device })` | the same, once the code is right |
95
+ | `secondFactor.recover(challenge, code, { device })` | the same, after `user.recoveryCodeUsed` |
96
+ | `signInCode.confirm(challenge, code, { device })` | the same — or a challenge, with an active second factor |
97
+ | `magicLink.confirm(token, { device })` | the same — or a challenge, with an active second factor |
98
+
99
+ A step-up opens no session, and takes no device. With several user types,
100
+ the calls are the type's: `clinic.staff.signIn(input, { device })`.
101
+
102
+ What `device` says:
103
+
104
+ | `device` | Means | The answer |
105
+ | --- | --- | --- |
106
+ | a string | the token the client holds — an earlier answer's `deviceToken` | `newDevice: false` and the same token when it proves this user signed in there; otherwise `newDevice: true` and a fresh token |
107
+ | `null` | the client holds no token yet | `newDevice: true` and a fresh token — `false` on `signUp` |
108
+ | absent | **devices are not tracked for this call** | `newDevice: false`, `deviceToken: null`, no event |
109
+
110
+ **A token that proves nothing is a new device, never an error.** Malformed,
111
+ forged, another user's, or signed with a key no longer held: the sign-in
112
+ succeeds, answers `newDevice: true` and a fresh token. The value came from a
113
+ request, so nothing about it is refused — only its type, from JavaScript.
114
+
115
+ **`undefined` is not `null`.** A cookie read as `string | undefined` and
116
+ handed over as it is makes every browser without a cookie untracked: it is
117
+ never given a token, and never reported. With `exactOptionalPropertyTypes`
118
+ in your tsconfig that is a compile error — `TS2379: … Type 'string |
119
+ undefined' is not assignable to type 'string | null'` — and without it, it
120
+ compiles and tracks nothing. Say what you mean:
121
+
122
+ ```ts
123
+ const cookie: string | undefined = readCookie(request, 'device'); // yours
124
+ await auth.signIn(credentials, { device: cookie ?? null });
125
+ ```
126
+
127
+ ## What the answer says
128
+
129
+ `SignedIn` — and so `RecoveredSignIn` — carries two more fields:
130
+
131
+ ```ts
132
+ interface SignedIn<U> {
133
+ readonly status: 'signedIn';
134
+ readonly user: U;
135
+ readonly session: Session;
136
+ readonly token: string;
137
+ readonly newDevice: boolean; // false without a device, and on signUp
138
+ readonly deviceToken: string | null; // null without a device: nothing minted
139
+ }
140
+ ```
141
+
142
+ - **`deviceToken`** is what the client keeps, and presents as `device` at its
143
+ next sign-in: the token it presented, a fresh one for a new device, or the
144
+ same device signed again with the first key after a
145
+ [rotation](#rotating-the-keys-and-forgetting-devices). Set the cookie again
146
+ on every sign-in: it keeps it alive.
147
+ - **`newDevice`** is `true` when the session was opened from a device the
148
+ user had not signed in from — and `user.newDeviceSignedIn` was then sent.
149
+
150
+ A test double that builds a `SignedIn` by hand needs both fields:
151
+ `{ …, newDevice: false, deviceToken: null }`.
152
+
153
+ ## With a second factor
154
+
155
+ A user whose second factor is active gets a challenge from `signIn`, and
156
+ **the challenge carries no device**. Give the device again to
157
+ `secondFactor.confirm` or `secondFactor.recover`, from the same cookie:
158
+
159
+ ```ts
160
+ const result = await auth.signIn(credentials, { device: cookie ?? null });
161
+ if (result.status === 'secondFactor') {
162
+ // …the next request, with the code:
163
+ const signedIn = await auth.secondFactor.confirm(result.challenge, code, { device: cookie ?? null });
164
+ signedIn.newDevice; // decided here, once the code is right
165
+ }
166
+ ```
167
+
168
+ The challenge answer has no `newDevice` and no `deviceToken`, and sends no
169
+ event: nothing is known until the code is. A confirmation given no device is
170
+ untracked, whatever `signIn` was given. The same holds for a
171
+ `signInCode.confirm` or `magicLink.confirm` that answered a challenge.
172
+
173
+ ## Keeping the token: the cookie
174
+
175
+ The token is made of cookie-value characters — `d1.<key id>.<device id>.<mac>`
176
+ — so it goes into a cookie as it is. Keep it in **its own long-lived cookie**,
177
+ `HttpOnly` and `Secure`, apart from the session cookie: it outlives every
178
+ session, and signing out leaves it, so the device stays known.
179
+
180
+ With Hono, [`@nxgt/janus-hono`](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus-hono/README.md)
181
+ reads it with `deviceOf(c)` and sets it in `sendSession` whenever the answer
182
+ carries a `deviceToken`:
183
+
184
+ ```ts
185
+ import { deviceOf, sendSession } from '@nxgt/janus-hono';
186
+
187
+ app.post('/sign-in', async (c) => {
188
+ const { email, password } = await c.req.json();
189
+ const signedIn = await auth.signIn({ email, password }, { device: deviceOf(c) });
190
+ const user = sendSession(c, auth, signedIn); // the session cookie, and the device cookie
191
+ return c.json({ id: user.id });
192
+ });
193
+ ```
194
+
195
+ Anywhere else, a fetch-style handler sets both cookies itself:
196
+
197
+ ```ts
198
+ const DEVICE = 'device';
199
+ const FOUR_HUNDRED_DAYS = 400 * 24 * 60 * 60; // the longest a browser keeps a cookie
200
+
201
+ function cookieOf(request: Request, name: string): string | null {
202
+ const match = request.headers.get('cookie')?.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`));
203
+ return match?.[1] || null; // no cookie, or an empty one: the client holds no token
204
+ }
205
+
206
+ export async function signIn(request: Request): Promise<Response> {
207
+ const { email, password } = (await request.json()) as { email: string; password: string };
208
+ const signedIn = await auth.signIn({ email, password }, { device: cookieOf(request, DEVICE) });
209
+ const headers = new Headers();
210
+ headers.append('Set-Cookie', auth.cookie.serialize(signedIn.token, signedIn.session));
211
+ if (signedIn.deviceToken !== null) {
212
+ headers.append(
213
+ 'Set-Cookie',
214
+ `${DEVICE}=${signedIn.deviceToken}; Max-Age=${FOUR_HUNDRED_DAYS}; Path=/; HttpOnly; Secure; SameSite=Lax`,
215
+ );
216
+ }
217
+ return Response.json({ id: signedIn.user.id }, { headers });
218
+ }
219
+ ```
220
+
221
+ A bearer client — a mobile app — keeps `deviceToken` beside its session
222
+ token, and sends it with its next sign-in.
223
+
224
+ **One cookie holds one user's token.** Two people who share a browser and
225
+ take turns signing in each present the other's token — which proves nothing
226
+ for them — and each gets a new token and a notice at every turn. That is
227
+ the price of storing nothing.
228
+
229
+ ## The event: `user.newDeviceSignedIn`
230
+
231
+ A sign-in from a new device sends
232
+ [`user.newDeviceSignedIn`](events.md#the-eleven-types) to `janus({ events })`:
233
+
234
+ ```ts
235
+ {
236
+ id: '0199…', // a UUIDv7 minted for this event
237
+ type: 'user.newDeviceSignedIn',
238
+ occurredAt: Date, // the new session's createdAt
239
+ userId: '0199…',
240
+ userType: 'user',
241
+ sessionId: '0199…', // the session the new device holds
242
+ }
243
+ ```
244
+
245
+ It is the second exception to *the user by id only*, after `formerEmail`:
246
+ `sessionId` names the session the new device holds, so a "this wasn't me"
247
+ page can end that one — `store.sessions.revokeSession(sessionId, new Date())`
248
+ on the store you wired — or every session with
249
+ `auth.signOutEverywhere(user)`. `@nxgt/janus-webhooks` posts the event
250
+ without it: a session id stays in the process.
251
+
252
+ - **Sent once the sign-in is complete.** A wrong password sends nothing, nor
253
+ does a `signIn` refused after its session opened — a password written
254
+ meanwhile — nor a challenge.
255
+ - **After the others.** `secondFactor.recover` sends it after
256
+ `user.recoveryCodeUsed`; a first proof of the e-mail by
257
+ [code](sign-in-code.md) or [link](magic-link.md) sends it last, after
258
+ `user.emailVerified` and what that proof removed.
259
+ - **Never** from `signUp`, from a call given no device, or from a device the
260
+ token proved.
261
+
262
+ ## The e-mail: `@nxgt/janus-mail`'s `newSignIn`
263
+
264
+ [`@nxgt/janus-mail`](https://www.npmjs.com/package/@nxgt/janus-mail) sends
265
+ the notice, in English and French — "New sign-in to your account", the
266
+ device, where, when, and a button to your security settings. Every value is
267
+ your text, in the recipient's locale: **Janus sees no IP and no
268
+ `User-Agent`**, so describing the device, formatting the time in the user's
269
+ time zone and finding a location — from a geo-IP of your own, if you have one
270
+ — are yours. Without a location, the e-mail says "Unknown" in its place —
271
+ `Location Unknown`, `Lieu Inconnu` in French — since `@nxgt/janus-mail` 0.9.1.
272
+
273
+ From the sign-in's answer, where the request is in hand:
274
+
275
+ ```ts
276
+ // `mail` is janusMail({ … }), and the user schema holds a name and a locale.
277
+ const signedIn = await auth.signIn({ email, password }, { device: cookieOf(request, DEVICE) });
278
+ if (signedIn.newDevice) {
279
+ const { user } = signedIn;
280
+ await mail.newSignIn(
281
+ { name: user.name, email: user.email, locale: user.locale },
282
+ {
283
+ device: describeDevice(request.headers.get('user-agent')), // yours: 'Firefox on macOS'
284
+ time: new Intl.DateTimeFormat(user.locale, { dateStyle: 'long', timeStyle: 'short', timeZone }).format(
285
+ signedIn.session.createdAt,
286
+ ),
287
+ },
288
+ );
289
+ }
290
+ ```
291
+
292
+ Or from the event, which names the user by id alone:
293
+
294
+ ```ts
295
+ const auth = janus({
296
+ ...config,
297
+ async events(event) {
298
+ if (event.type !== 'user.newDeviceSignedIn') return;
299
+ const user = await auth.get(event.userId);
300
+ const time = new Intl.DateTimeFormat(user.locale, { dateStyle: 'long', timeStyle: 'short', timeZone }).format(
301
+ event.occurredAt,
302
+ );
303
+ await mail.newSignIn({ name: user.name, email: user.email, locale: user.locale }, { device: 'a new device', time });
304
+ },
305
+ });
306
+ ```
307
+
308
+ The event has no request to describe the device from: send from the answer
309
+ when you want the device named, or put the description in the job your
310
+ route queues.
311
+
312
+ ## What a token proves, and what it does not
313
+
314
+ A device token is `d1.<key id>.<device id>.<mac>`: 16 random bytes of device
315
+ id, and an HMAC-SHA256 of the user's id and the device id, under a key
316
+ derived (HKDF) from the device key. So:
317
+
318
+ - **It is bound to the user.** A token copied onto another user's sign-in
319
+ proves nothing there, and is a new device.
320
+ - **It cannot be forged** without the key, and a changed character proves
321
+ nothing.
322
+ - **It proves only "this user signed in here before".** It is not a
323
+ session and not a credential: never authenticate a request with it, never
324
+ let it skip a password or a second factor. Whoever copies the cookie
325
+ copies a device, and the worst it buys is one notice fewer.
326
+
327
+ ## Rotating the keys, and forgetting devices
328
+
329
+ The first key signs; every key checks. To rotate, put a new key first and
330
+ keep the old one:
331
+
332
+ ```ts
333
+ devices: {
334
+ keys: [
335
+ { id: '2027-03', key: process.env.DEVICES_KEY_2027_03 ?? '' }, // signs
336
+ { id: '2026-09', key: process.env.DEVICES_KEY_2026_09 ?? '' }, // still checks
337
+ ],
338
+ },
339
+ ```
340
+
341
+ A token signed by the older key is known, and the answer's `deviceToken` is
342
+ the same device signed again with the first: every device that signs in
343
+ moves to the new key by itself.
344
+
345
+ **Removing a key is the only way to forget devices, and it forgets every
346
+ device that key signed.** Each one is reported new once, at its next
347
+ sign-in, then known again. There is no way to forget one device alone —
348
+ nothing is stored to delete. After a breach, drop every key and start with a
349
+ new one: every user gets one notice per device.
350
+
351
+ ## Errors
352
+
353
+ All three are bare `TypeError`s — wiring mistakes, thrown before anything is
354
+ written. `<call>` is the call you wrote: `signIn`, `secondFactor.confirm`, or
355
+ `patient.signIn` with several user types.
356
+
357
+ | Message | Cause |
358
+ | --- | --- |
359
+ | `<call>: a device was given, but janus() has no devices — pass devices: { keys }` | `{ device }`, even `null`, given to a `janus()` wired without `devices` |
360
+ | `<call>: options must be { device }, or left out — the token goes under device` | options that are not an object — the token itself where `{ device }` belongs — from JavaScript or through a cast |
361
+ | `<call>: options.device must be the device token the client holds, or null when it holds none` | a `device` that is neither a string nor `null`, from JavaScript or through a cast |
362
+
363
+ The configuration's are [above](#wiring). Every call may still reject as it
364
+ did — `CREDENTIALS_INVALID`, `CODE_INVALID`, `STORE_FAILED` — and a device
365
+ never adds a refusal of its own.
366
+
367
+ ## In a test
368
+
369
+ ```ts
370
+ import { expect, it } from 'bun:test';
371
+ import { z } from 'zod';
372
+ import { createMemoryStores, janus, scryptHasher, type UserEvent } from '@nxgt/janus';
373
+
374
+ it('tells a new device from a known one', async () => {
375
+ const received: UserEvent[] = [];
376
+ const auth = janus({
377
+ user: z.object({ email: z.email() }),
378
+ password: { login: 'email' },
379
+ store: createMemoryStores(),
380
+ hasher: scryptHasher({ cost: 10 }), // fast in tests; keep the default in production
381
+ devices: { keys: [{ id: 'test', key: Buffer.alloc(32, 7).toString('base64') }] },
382
+ events: (event) => void received.push(event),
383
+ });
384
+ const credentials = { email: 'ada@example.com', password: 'correct horse' };
385
+ const { deviceToken } = await auth.signUp(credentials, { device: null });
386
+
387
+ const known = await auth.signIn(credentials, { device: deviceToken });
388
+ const fresh = await auth.signIn(credentials, { device: null });
389
+
390
+ expect(known.newDevice).toBe(false);
391
+ expect(fresh.newDevice).toBe(true);
392
+ expect(received.filter((event) => event.type === 'user.newDeviceSignedIn')).toEqual([
393
+ expect.objectContaining({ userId: fresh.user.id, sessionId: fresh.session.id }),
394
+ ]);
395
+ });
396
+ ```
397
+
398
+ ## Signatures
399
+
400
+ ```ts
401
+ interface SignInOptions {
402
+ readonly device?: string | null; // the token the client holds, or null for none; absent: untracked
403
+ }
404
+
405
+ // On every user type with a password:
406
+ signUp(input: In & { readonly password: string }, options?: SignInOptions): Promise<SignedIn<U>>;
407
+ signIn(input: { readonly [K in Login]: string } & { readonly password: string }, options?: SignInOptions): Promise<Answer>;
408
+ secondFactor.confirm(challenge: string, code: string, options?: SignInOptions): Promise<SignedIn<U>>;
409
+ secondFactor.recover(challenge: string, code: string, options?: SignInOptions): Promise<RecoveredSignIn<U>>;
410
+
411
+ // On every user type with an e-mail:
412
+ signInCode.confirm(challenge: string, code: string, options?: SignInOptions): Promise<Answer>;
413
+ magicLink.confirm(token: string, options?: SignInOptions): Promise<Answer>;
414
+
415
+ janus({ ..., devices?: DevicesConfig });
416
+ ```
417
+
418
+ `Answer` is `SignInResult<U>` on a type with a password in an instance given
419
+ a `secondFactor`, and `SignedIn<U>` everywhere else. `DevicesConfig`,
420
+ `SignInOptions` and `SealingKey` are exported types of `@nxgt/janus`.
421
+
422
+ ## See also
423
+
424
+ - [User events](events.md) — `user.newDeviceSignedIn` among the eleven types, and when the listener runs
425
+ - [The second factor](second-factor.md#confirming-the-code-at-sign-in) — the challenge the device is given again after
426
+ - [Sessions](sessions.md) — the session cookie, beside the device cookie
427
+ - [`@nxgt/janus-hono`](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus-hono/docs/guide/routes.md#the-device-cookie) — `deviceOf` and `sendSession`'s device cookie
428
+ - [`@nxgt/janus-mail`](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus-mail/docs/guide/sending.md#newsigninto--device-time-location-) — `newSignIn`
429
+ - [Troubleshooting](../troubleshooting.md#devices) — every sign-in reports a new device, and the messages above
@@ -2,7 +2,8 @@
2
2
 
3
3
  This page is for hearing what happens to a user once it is written: created,
4
4
  e-mail verified, password reset or changed, e-mail changed, second factor
5
- turned on or off, recovery codes regenerated or one used, deleted. Another service can then follow
5
+ turned on or off, recovery codes regenerated or one used, a sign-in from a
6
+ new device, deleted. Another service can then follow
6
7
  without polling. `janus` hands each event to one function you give it;
7
8
  **delivering it is yours** — or
8
9
  [`@nxgt/janus-webhooks`](https://www.npmjs.com/package/@nxgt/janus-webhooks)'s:
@@ -38,7 +39,7 @@ received[0];
38
39
  The words — **user event**, **listener** — are defined in
39
40
  [the vocabulary](vocabulary.md#identities).
40
41
 
41
- ## The ten types
42
+ ## The eleven types
42
43
 
43
44
  | `type` | Sent by | Not sent |
44
45
  | --- | --- | --- |
@@ -51,16 +52,18 @@ The words — **user event**, **listener** — are defined in
51
52
  | `user.secondFactorDisabled` | `secondFactor.disable`, when it removed an **active** factor; `signInCode.confirm` and `magicLink.confirm` 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` |
52
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 |
53
54
  | `user.recoveryCodeUsed` | `secondFactor.recover`, once the recovery code is spent — even if opening the session then fails | for a recovery code refused. How many are left is not in the event: `secondFactor.recoveryCodesLeft(event.userId)` reads it |
55
+ | `user.newDeviceSignedIn` | `signIn`, `secondFactor.confirm`, `secondFactor.recover`, `signInCode.confirm` and `magicLink.confirm`, given a `device` whose token did not prove this user signed in there — once the sign-in is complete. It carries `sessionId`, the new session's. See [devices](devices.md) | for `signUp`, which mints the first token; for a call given no `device`; for a known device; for a challenge, which opens no session; for a sign-in refused — a wrong password, or a `signIn` refused after its session opened because a password was written meanwhile |
54
56
  | `user.deleted` | `delete`, when it deleted the user | for a replay that finds nobody, or an id of another user type |
55
57
 
56
58
  A reset whose link verifies the e-mail sends both, `user.passwordReset`
57
59
  first. **A reset does not also send `user.passwordChanged`**: a listener that
58
60
  cares about every new password handles both types, as the `switch` below
59
61
  does — one event per write, rather than two a listener would have to tell
60
- apart. Switch on `type`: the ten are a closed set, and TypeScript refuses an
61
- eleventh. A new type is a compile error in a `switch` that exhausts them — the
62
- two recovery-code types, added in 0.10, and the two change types, added in
63
- 0.14, each broke such a `switch` until it handled them.
62
+ apart. Switch on `type`: the eleven are a closed set, and TypeScript refuses a
63
+ twelfth. A new type is a compile error in a `switch` that exhausts them — the
64
+ two recovery-code types, added in 0.10, the two change types, added in
65
+ 0.14, and `user.newDeviceSignedIn`, added in 0.17, each broke such a `switch`
66
+ until it handled them.
64
67
 
65
68
  ```ts
66
69
  function onUserEvent(event: UserEvent): void {
@@ -81,6 +84,8 @@ function onUserEvent(event: UserEvent): void {
81
84
  return;
82
85
  case 'user.recoveryCodeUsed':
83
86
  return warnTheUser(event.userId); // a sign-in without their phone — see below
87
+ case 'user.newDeviceSignedIn':
88
+ return warnTheUser(event.userId); // @nxgt/janus-mail's newSignIn — see below
84
89
  case 'user.deleted':
85
90
  return forgetEverywhere(event.userId);
86
91
  }
@@ -123,6 +128,37 @@ With several user types, the count is the type's:
123
128
  `auth.patient.secondFactor.recoveryCodesLeft(event.userId)`, on
124
129
  `event.userType`.
125
130
 
131
+ ### Telling the user a new device signed in
132
+
133
+ A sign-in from a device the user had not signed in from is how a stolen
134
+ password shows, so tell the user.
135
+ [`@nxgt/janus-mail`](https://www.npmjs.com/package/@nxgt/janus-mail)'s
136
+ `newSignIn` is the e-mail — the device, the time and, when you know it, the
137
+ place, each your text in the user's locale:
138
+
139
+ ```ts
140
+ // `mail` is janusMail({ … }), and the user schema holds a name and a locale.
141
+ const auth = janus({
142
+ ...config,
143
+ async events(event) {
144
+ if (event.type === 'user.newDeviceSignedIn') {
145
+ const user = await auth.get(event.userId);
146
+ const time = new Intl.DateTimeFormat(user.locale, {
147
+ dateStyle: 'long',
148
+ timeStyle: 'short',
149
+ timeZone: 'Europe/Paris', // the user's, when you keep it
150
+ }).format(event.occurredAt); // the new session's createdAt
151
+ await mail.newSignIn({ name: user.name, locale: user.locale, email: user.email }, { device: 'a new device', time });
152
+ }
153
+ },
154
+ });
155
+ ```
156
+
157
+ The event has no request, so it cannot say which device: send from the
158
+ sign-in's answer instead — `if (signedIn.newDevice) …` — to name it from
159
+ the `User-Agent`. [Devices](devices.md#the-e-mail-nxgtjanus-mails-newsignin)
160
+ has both.
161
+
126
162
  ### Telling the user their password or e-mail changed
127
163
 
128
164
  A password changed by someone else, or an e-mail moved to their inbox, is how
@@ -161,14 +197,14 @@ too, if a reset should be told as well.
161
197
 
162
198
  ## What an event carries
163
199
 
164
- **The user named by id, and nothing else** — with one exception, below. No
200
+ **The user named by id, and nothing else** — with two exceptions, below. No
165
201
  login, no field of the schema, no password or hash, no session or one-time
166
202
  token. An event can
167
203
  land in a queue, a log or another company's endpoint; whoever receives it
168
204
  reads the rest from where it is kept — `auth.get(event.userId)` — if they
169
205
  may. A user deleted since is `NOT_FOUND` there, which is the answer.
170
206
 
171
- **The exception is `formerEmail`, on `user.emailChanged` only**: the address
207
+ **The first exception is `formerEmail`, on `user.emailChanged` only**: the address
172
208
  the user had before the update, as it was stored, or `null` when they had
173
209
  none. Once the write landed it is kept nowhere else, and a notice to the
174
210
  inbox the account just left needs it. It is absent from every other type.
@@ -176,6 +212,11 @@ inbox the account just left needs it. It is absent from every other type.
176
212
  it reaches no endpoint, no queue and no `onGivingUp` report — send the
177
213
  notice from the listener, in the process that wrote.
178
214
 
215
+ **The second is `sessionId`, on `user.newDeviceSignedIn` only**: the id of
216
+ the session the new device opened, so a "this wasn't me" page can end that
217
+ one. It is absent from every other type, and `@nxgt/janus-webhooks` strips
218
+ it as it strips `formerEmail`.
219
+
179
220
  `id` is minted for the event, a UUIDv7 that sorts by time. Deliver on it:
180
221
  a queue job id, a primary key, a webhook's `webhook-id` header. Two events
181
222
  never share one.
@@ -207,6 +248,7 @@ Where the listener runs within a flow, and what an outage does to it:
207
248
  | `signInCode.confirm`, `magicLink.confirm` | 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 or link proves the e-mail again |
208
249
  | `secondFactor.activate`, `secondFactor.regenerateRecoveryCodes`, `secondFactor.disable` | after the write — the flow's last step | — |
209
250
  | `secondFactor.recover` | after the recovery code is spent and the session opened — or failed to open | fails the call; the recovery code is spent and the event sent all the same, from a `finally` |
251
+ | `signIn`, `secondFactor.confirm`, `secondFactor.recover`, `signInCode.confirm`, `magicLink.confirm` — `user.newDeviceSignedIn` | **last**, once the session is open and the sign-in complete: after `user.recoveryCodeUsed`, and after the events of a first proof of the e-mail | fails the call, and `user.newDeviceSignedIn` is not sent: it comes only once the sign-in is complete |
210
252
  | `resetPassword.confirm` | **after** the sessions opened with the old password are revoked, and the other reset links and the second-factor challenges left open are spent | fails the call; the events are sent all the same, from a `finally` |
211
253
  | `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` |
212
254
 
@@ -294,6 +336,7 @@ type UserEventType =
294
336
  | 'user.secondFactorDisabled'
295
337
  | 'user.recoveryCodesRegenerated'
296
338
  | 'user.recoveryCodeUsed'
339
+ | 'user.newDeviceSignedIn'
297
340
  | 'user.deleted';
298
341
 
299
342
  interface UserEvent {
@@ -303,6 +346,7 @@ interface UserEvent {
303
346
  readonly userId: Id;
304
347
  readonly userType: string;
305
348
  readonly formerEmail?: string | null; // on user.emailChanged only: the address before, null for none
349
+ readonly sessionId?: string; // on user.newDeviceSignedIn only: the session the new device opened
306
350
  }
307
351
 
308
352
  type UserEventListener = (event: UserEvent) => void | Promise<void>;
@@ -317,6 +361,7 @@ see [troubleshooting](../troubleshooting.md#janus-events-must-be-a-function-that
317
361
 
318
362
  - [E-mail verification and password reset](email-flows.md) — the flows that send `user.emailVerified` and `user.passwordReset`
319
363
  - [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
+ - [Devices](devices.md) — the sign-ins that send `user.newDeviceSignedIn`, the device token and the cookie
320
365
  - [Users](users.md) — `create`, `signUp`, `update`, `delete`
321
366
  - [Passwords](passwords.md) — `changePassword` and `setPassword`, which send `user.passwordChanged`
322
367
  - [Troubleshooting](../troubleshooting.md) — `JANUS_EVENT_FAILED`
@@ -286,6 +286,25 @@ a password, in an instance given a `secondFactor` — and reading `token`
286
286
  before narrowing on `status` is a compile error there. Anywhere else it
287
287
  answers `SignedIn`.
288
288
 
289
+ ### The device
290
+
291
+ With [`janus({ devices })`](devices.md), `confirm` takes the device token
292
+ the client holds as its second argument — read from the device cookie of
293
+ the browser that posts the link's page, which is the one the session opens
294
+ in:
295
+
296
+ ```ts
297
+ const signedIn = await auth.magicLink.confirm(token, { device: cookie ?? null });
298
+ // { status: 'signedIn', …, newDevice, deviceToken }: keep deviceToken in the device cookie
299
+ ```
300
+
301
+ A new device sends `user.newDeviceSignedIn` — last, after the events a first
302
+ proof of the e-mail sends. A link opened on a phone the user never signed in
303
+ from is a new device, even when a laptop asked for it. When `confirm`
304
+ answers a second factor's challenge, the challenge carries no device: give
305
+ it again to `secondFactor.confirm`. Without `{ device }`, nothing is
306
+ tracked.
307
+
289
308
  ### What `confirm` refuses
290
309
 
291
310
  | Rejects with | When | What to do |
@@ -414,14 +433,16 @@ it('signs in with the e-mailed link, once, and not after fifteen minutes', async
414
433
  interface MagicLinkApi<U, Answer = SignedIn<U>> {
415
434
  readonly magicLink: {
416
435
  request(email: string): Promise<(IssuedToken & { readonly user: U }) | null>;
417
- confirm(token: string): Promise<Answer>;
436
+ confirm(token: string, options?: SignInOptions): Promise<Answer>;
418
437
  };
419
438
  }
420
439
  ```
421
440
 
422
441
  `Answer` is `SignInResult<U>` on a type with a password in an instance given
423
- a `secondFactor`, and `SignedIn<U>` everywhere else. `MagicLinkApi` and
424
- `IssuedToken` are exported from `@nxgt/janus`, as types.
442
+ a `secondFactor`, and `SignedIn<U>` everywhere else. `MagicLinkApi`,
443
+ `IssuedToken` and `SignInOptions` are exported from `@nxgt/janus`, as
444
+ types; `SignInOptions` is `{ device?: string | null }` — see
445
+ [devices](devices.md#giving-a-sign-in-the-device).
425
446
 
426
447
  ## For an adapter
427
448
 
@@ -437,6 +458,7 @@ holds that a link and a sign-in code are never redeemed as each other. See
437
458
  - [The second factor](second-factor.md) — the challenge `confirm` answers for a user whose factor is active
438
459
  - [E-mail verification and password reset](email-flows.md) — the other flows that send a link, and `TOKEN_STALE`
439
460
  - [Sessions](sessions.md) — the cookie the session is sent in
461
+ - [Devices](devices.md) — the `device` `confirm` takes, and the notice of a new one
440
462
  - [Errors](errors.md) — every code and its status
441
463
  - [Troubleshooting](../troubleshooting.md#sign-in-links) — by the message you see
442
464
  - [`@nxgt/janus-telemetry`](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus-telemetry/docs/guide/tracing.md#a-link-sent-by-e-mail) — the events a sign-in by link writes