@nxgt/janus 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) 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 +428 -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/step-up.md +33 -0
  62. package/docs/guide/users.md +3 -2
  63. package/docs/guide/vocabulary.md +4 -3
  64. package/docs/roadmap.md +12 -8
  65. package/docs/troubleshooting.md +210 -4
  66. package/package.json +1 -1
@@ -213,6 +213,8 @@ interface SignedIn<U> {
213
213
  readonly user: U;
214
214
  readonly session: Session;
215
215
  readonly token: string;
216
+ readonly newDevice: boolean; // with janus({ devices }): see devices.md
217
+ readonly deviceToken: string | null;
216
218
  }
217
219
 
218
220
  interface SecondFactorRequired {
@@ -284,6 +286,26 @@ const signedIn = await auth.secondFactor.confirm(challenge, code);
284
286
  | `USER_INACTIVE` | the user was deactivated since `signIn`. The challenge is spent | answer 403, as `signIn` would |
285
287
  | `SECOND_FACTOR_NOT_ENROLLED` | the factor was disabled since `signIn`. The challenge is spent | sign in again: the password alone now opens a session |
286
288
 
289
+ ### Giving the device again
290
+
291
+ With [`janus({ devices })`](devices.md), the challenge carries **no
292
+ device**: whatever `signIn` was given, `confirm` — and `recover` — decide
293
+ whether the device is new only from what they are given themselves. Pass
294
+ the same `{ device }` again, from the same cookie:
295
+
296
+ ```ts
297
+ const result = await auth.signIn({ email, password }, { device: cookie ?? null });
298
+ if (result.status === 'secondFactor') {
299
+ // the next request, with the code — and the device cookie again
300
+ const signedIn = await auth.secondFactor.confirm(challenge, code, { device: cookie ?? null });
301
+ // { status: 'signedIn', …, newDevice, deviceToken }: user.newDeviceSignedIn sent for a new one
302
+ }
303
+ ```
304
+
305
+ A confirmation given no device is untracked: `newDevice: false`,
306
+ `deviceToken: null`, no event. The challenge answer itself has neither
307
+ field, and sends nothing.
308
+
287
309
  ### Attempts
288
310
 
289
311
  A challenge takes **five attempts**. Every call to `confirm` counts one, in
@@ -392,7 +414,7 @@ and `recoveryCodesLeft` says how many remain.
392
414
  type RecoveredSignIn<U> = SignedIn<U> & { readonly recoveryCodesLeft: number };
393
415
 
394
416
  const signedIn = await auth.secondFactor.recover(challenge, ' 7K2MQ X9D4C ');
395
- // { status: 'signedIn', user, session, token, recoveryCodesLeft: 9 }
417
+ // { status: 'signedIn', user, session, token, newDevice, deviceToken, recoveryCodesLeft: 9 }
396
418
  ```
397
419
 
398
420
  - **Read as the user may type it**: case, spaces and dashes are ignored, `o`
@@ -421,7 +443,8 @@ at the same moment, exactly one gets a session.
421
443
  `recover` sends a [`user.recoveryCodeUsed` event](events.md) once the code is
422
444
  spent — even if opening the session then fails. It is the one to tell the
423
445
  user about: a recovery code used by someone else is a sign-in without their
424
- phone.
446
+ phone. Given a [`device`](devices.md) that is new, it sends
447
+ `user.newDeviceSignedIn` after it.
425
448
 
426
449
  ### Counting what is left
427
450
 
@@ -875,8 +898,8 @@ interface SecondFactorApi<U> {
875
898
  activate(user: UserRef, code: string, options?: WriteOptions): Promise<RecoveryCodesIssued<U>>;
876
899
  regenerateRecoveryCodes(user: UserRef, code: string, options?: WriteOptions): Promise<RecoveryCodesIssued<U>>;
877
900
  disable(user: UserRef, options?: WriteOptions): Promise<U>;
878
- confirm(challenge: string, code: string): Promise<SignedIn<U>>;
879
- recover(challenge: string, code: string): Promise<RecoveredSignIn<U>>; // code: a recovery code
901
+ confirm(challenge: string, code: string, options?: SignInOptions): Promise<SignedIn<U>>;
902
+ recover(challenge: string, code: string, options?: SignInOptions): Promise<RecoveredSignIn<U>>; // code: a recovery code
880
903
  recoveryCodesLeft(user: UserRef): Promise<number | null>; // null: no active factor
881
904
  };
882
905
  }
@@ -890,7 +913,8 @@ type RecoveredSignIn<U> = SignedIn<U> & { readonly recoveryCodesLeft: number };
890
913
  ```
891
914
 
892
915
  `RecoveryCodesIssued` and `RecoveredSignIn` are exported types of
893
- `@nxgt/janus`.
916
+ `@nxgt/janus`. `SignInOptions` is `{ device?: string | null }` — see
917
+ [devices](devices.md#giving-a-sign-in-the-device).
894
918
 
895
919
  `UserRef` is a user or its id; `WriteOptions` is `{ ifVersion? }`, as on
896
920
  every write — see [Users](users.md#ifversion). Every call may also reject
@@ -901,6 +925,7 @@ with `STORE_FAILED`.
901
925
  - [Sign-in codes](sign-in-code.md) — a sign-in by e-mailed code, which still asks for an active factor, with the same challenge
902
926
  - [Sign-in links](magic-link.md) — a sign-in by e-mailed link, which does the same
903
927
  - [Sessions](sessions.md) — the cookie `confirm`'s session is sent in, and `authenticatedAt`
928
+ - [Devices](devices.md) — the `device` `confirm` and `recover` are given again, and the notice of a new one
904
929
  - [User events](events.md) — `user.secondFactorEnabled`, `user.secondFactorDisabled`, `user.recoveryCodesRegenerated` and `user.recoveryCodeUsed`
905
930
  - [Errors](errors.md) — `CODE_INVALID`, `SECOND_FACTOR_NOT_ENROLLED`, `SECOND_FACTOR_ACTIVE` and their statuses
906
931
  - [Writing an adapter](adapters.md#a-users-password-and-second-factor) — what a store keeps of a factor and its recovery codes
@@ -168,6 +168,13 @@ const { user, session, token } = result; // status 'signedIn': as above
168
168
  its session is sent the same way. See
169
169
  [the second factor](second-factor.md#signing-in-switch-on-status).
170
170
 
171
+ **With `janus({ devices })`, a second cookie rides beside the session's.**
172
+ A sign-in given `{ device }` answers a `deviceToken` for the client to keep
173
+ in a long-lived cookie of its own, apart from the session cookie: it
174
+ outlives every session, and a sign-out leaves it, so the device stays
175
+ known. `auth.cookie` builds only the session cookie — see
176
+ [devices](devices.md#keeping-the-token-the-cookie) for the device cookie.
177
+
171
178
  `signOutEverywhere(user, { except })` revokes every standing session of a
172
179
  user, but the one named, and answers how many it revoked — "sign out
173
180
  everywhere else". An `except` that names no session of theirs — an unknown
@@ -237,5 +244,6 @@ cannot be replayed. `Session` is the stored record without that hash: `id`,
237
244
  - [Users](users.md) — `signUp`, `signIn`, the configuration
238
245
  - [The second factor](second-factor.md) — the challenge `signIn` answers instead of a session
239
246
  - [Step-up](step-up.md) — proving again who you are, and `assertFresh`
247
+ - [Devices](devices.md) — the device cookie beside the session cookie, and a sign-in from a new device
240
248
  - [E-mail flows](email-flows.md) — verification and password reset
241
249
  - [Errors](errors.md) — `STORE_FAILED`, `UNSUPPORTED` and the rest
@@ -312,6 +312,22 @@ a password, in an instance given a `secondFactor` — and reading `token`
312
312
  before narrowing on `status` is a compile error there. Anywhere else it
313
313
  answers `SignedIn`.
314
314
 
315
+ ### The device
316
+
317
+ With [`janus({ devices })`](devices.md), `confirm` takes the device token
318
+ the client holds as its third argument, and tells a sign-in from a new
319
+ device as `signIn` does:
320
+
321
+ ```ts
322
+ const signedIn = await auth.signInCode.confirm(challenge, code, { device: cookie ?? null });
323
+ // { status: 'signedIn', …, newDevice, deviceToken }: keep deviceToken in the device cookie
324
+ ```
325
+
326
+ A new device sends `user.newDeviceSignedIn` — last, after the events a first
327
+ proof of the e-mail sends. When `confirm` answers a second factor's
328
+ challenge, the challenge carries no device: give it again to
329
+ `secondFactor.confirm`. Without `{ device }`, nothing is tracked.
330
+
315
331
  ### What `confirm` refuses
316
332
 
317
333
  | Rejects with | When | What to do |
@@ -499,14 +515,16 @@ it('signs in with the e-mailed code, and not after ten minutes', async () => {
499
515
  interface SignInCodeApi<U, Answer = SignedIn<U>> {
500
516
  readonly signInCode: {
501
517
  request(email: string): Promise<IssuedCode<U> | null>;
502
- confirm(challenge: string, code: string): Promise<Answer>;
518
+ confirm(challenge: string, code: string, options?: SignInOptions): Promise<Answer>;
503
519
  };
504
520
  }
505
521
  ```
506
522
 
507
523
  `Answer` is `SignInResult<U>` on a type with a password in an instance given
508
- a `secondFactor`, and `SignedIn<U>` everywhere else. `IssuedCode` and
509
- `SignInCodeApi` are exported from `@nxgt/janus`, as types.
524
+ a `secondFactor`, and `SignedIn<U>` everywhere else. `IssuedCode`,
525
+ `SignInCodeApi` and `SignInOptions` are exported from `@nxgt/janus`, as
526
+ types; `SignInOptions` is `{ device?: string | null }` — see
527
+ [devices](devices.md#giving-a-sign-in-the-device).
510
528
 
511
529
  ## See also
512
530
 
@@ -514,6 +532,7 @@ a `secondFactor`, and `SignedIn<U>` everywhere else. `IssuedCode` and
514
532
  - [Sign-in links](magic-link.md) — the same sign-in with a link instead of a code: nothing to type, on whichever device opens it
515
533
  - [E-mail verification and password reset](email-flows.md) — the other flows that send an e-mail, and `TOKEN_STALE`
516
534
  - [Sessions](sessions.md) — the cookie the session is sent in
535
+ - [Devices](devices.md) — the `device` `confirm` takes, and the notice of a new one
517
536
  - [Errors](errors.md) — every code and its status
518
537
  - [`@nxgt/janus-telemetry`](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus-telemetry/docs/guide/tracing.md#a-code-sent-by-e-mail) — the events a sign-in by code writes
519
538
  - [Troubleshooting](../troubleshooting.md#sign-in-codes) — by the message you see
@@ -30,6 +30,12 @@ const session = await auth.stepUp.confirm(request, issued.challenge, code);
30
30
  session.authenticatedAt; // now: the action goes through for the next ten minutes
31
31
  ```
32
32
 
33
+ `sendMail` stands for your own sending. With
34
+ [`@nxgt/janus-mail`](https://www.npmjs.com/package/@nxgt/janus-mail) 0.8,
35
+ `mail.stepUp(issued, { name, locale })` sends it — "Your confirmation code",
36
+ in English or French — see [Sending the code with
37
+ `@nxgt/janus-mail`](#sending-the-code-with-nxgtjanus-mail).
38
+
33
39
  A step-up is two requests, as a sign-in by code is: one issues the code, the
34
40
  other takes it and stamps the session. It **opens no session** and hands out
35
41
  no new token: the session the request presents is the one confirmed, which
@@ -110,6 +116,31 @@ if (issued.via === 'email') {
110
116
  return Response.json({ challenge: issued.challenge, via: issued.via });
111
117
  ```
112
118
 
119
+ ### Sending the code with `@nxgt/janus-mail`
120
+
121
+ `@nxgt/janus-mail`'s `mail.stepUp` takes what `request` answered, once
122
+ narrowed to `via: 'email'`, and sends it to `issued.email`: a greeting by
123
+ name, the code, how long it lasts — "This code expires in 10 minutes." —
124
+ and a **Secure my account** button to its `links.secureAccount()`, for a
125
+ user who asked for nothing. It reads the code, the address and `expiresAt`,
126
+ never the challenge:
127
+
128
+ ```ts
129
+ import { janusMail } from '@nxgt/janus-mail';
130
+
131
+ const mail = janusMail({ mailer, from: 'noreply@acme.example', brand: 'Acme', links });
132
+
133
+ const issued = await auth.stepUp.request(current.user);
134
+ if (issued.via === 'email') {
135
+ await mail.stepUp(issued, { name: current.user.name, locale: current.user.locale });
136
+ }
137
+ return Response.json({ challenge: issued.challenge, via: issued.via });
138
+ ```
139
+
140
+ An un-narrowed answer is a compile error there, and a `via: 'secondFactor'`
141
+ passed from JavaScript a `TypeError`: an app's step-up has nothing to send.
142
+ See its [sending guide](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus-mail/docs/guide/sending.md#stepupissued-to-options).
143
+
113
144
  **One step-up is live per user**: a request spends every step-up issued
114
145
  before it. The challenge lapses after ten minutes by default —
115
146
  `janus({ tokens: { stepUp: '5m' } })` changes it.
@@ -176,3 +207,5 @@ calls are traced like every other flow.
176
207
  - [Signing in with an e-mailed code](sign-in-code.md) — the same six digits,
177
208
  to sign in.
178
209
  - [Errors](errors.md) — every code and its status.
210
+ - [`@nxgt/janus-mail`](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus-mail/docs/guide/sending.md#stepupissued-to-options)
211
+ — `mail.stepUp`, the step-up's e-mail.
@@ -99,6 +99,7 @@ that cuts an emoji in half, say.
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
101
  | `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) |
102
+ | `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) |
102
103
  | `events` | `UserEventListener` | none | Called with every user event — `user.created`, `user.deleted`, … — after the write, awaited. See [user events](events.md) |
103
104
 
104
105
  A `Duration` is `'500ms'`, `'30s'`, `'15m'`, `'8h'`, `'7d'`, or a number of
@@ -188,8 +189,8 @@ With a `password`, besides:
188
189
 
189
190
  | Method | Answers | Rejects with |
190
191
  | --- | --- | --- |
191
- | `signUp(fields & { password })` | `{ status: 'signedIn', user, session, token }` | `USER_INVALID`, `PASSWORD_TOO_SHORT`, `LOGIN_TAKEN` |
192
- | `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` — with `retryAfter` once the login is throttled — `USER_INACTIVE`, `HASH_UNSUPPORTED` |
192
+ | `signUp(fields & { password }, { device? })` | `{ status: 'signedIn', user, session, token, newDevice, deviceToken }` — `newDevice` always `false`; a first `deviceToken` when given a `device` ([devices](devices.md)) | `USER_INVALID`, `PASSWORD_TOO_SHORT`, `LOGIN_TAKEN` |
193
+ | `signIn({ [login]: string, password }, { device? })` | `{ status: 'signedIn', user, session, token, newDevice, deviceToken }` — or, with `secondFactor` configured and the user's factor active, `{ status: 'secondFactor', challenge, expiresAt, userId }`: switch on `status` | `CREDENTIALS_INVALID` — with `retryAfter` once the login is throttled — `USER_INACTIVE`, `HASH_UNSUPPORTED` |
193
194
  | `findByLogin(login)` | the user, or `null`; the login is normalised first, and one holding a NUL or a lone surrogate is nobody's | |
194
195
  | `setPassword(user, password, { ifVersion? })` | the user — an admin's call; spends the user's reset links and second-factor challenges still waiting, and signs nobody out; sends `user.passwordChanged` | `PASSWORD_TOO_SHORT` |
195
196
  | `changePassword(user, { current, next }, { ifVersion? })` | the user — the user's own call; spends the user's reset links and second-factor challenges still waiting; sends `user.passwordChanged` | `CREDENTIALS_INVALID`, `PASSWORD_TOO_SHORT` |
@@ -56,11 +56,12 @@ either finds this row.
56
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 `resetPassword.request` for the same user (at most one of each is live at a time), 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
- | **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 | |
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
+ | **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 | |
60
61
  | **e-mail flow** | `verifyEmail` or `resetPassword`: send a one-time token, then confirm it. `magicLink` sends one too, to sign in; `signInCode` sends a one-time code instead | |
61
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" |
62
- | **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 — and *two-factor enabled*, *two-factor disabled*. `@nxgt/janus-mail` sends all four | "alert", "notification" |
63
- | **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.deleted` — a `UserEvent`, naming the user by id alone — `user.emailChanged` adds `formerEmail` — 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 |
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
+ | **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 |
64
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 |
65
66
  | **listener** | The one function `janus({ events })` hands every user event to, after the write and awaited | "handler", "hook", "subscriber" |
66
67
 
package/docs/roadmap.md CHANGED
@@ -70,6 +70,18 @@ Nothing yet.
70
70
  The last ten, newest first, each with the version it came in. Everything
71
71
  before is in the [CHANGELOG](../CHANGELOG.md).
72
72
 
73
+ - **A sign-in from a new device, v0.17.0** — `janus({ devices: { keys } })`,
74
+ and `{ device }` as the last argument of `signUp`, `signIn`,
75
+ `secondFactor.confirm`, `secondFactor.recover`, `signInCode.confirm` and
76
+ `magicLink.confirm`: the device token the client holds, or `null`. Every
77
+ answer that opens a session says `newDevice` and hands back a
78
+ `deviceToken` for a long-lived cookie, and a new device sends
79
+ `user.newDeviceSignedIn`, with the new session's `sessionId`, once the
80
+ sign-in is complete. Nothing is stored: the token is a device id signed
81
+ for the user, so the store ports are unchanged. A sign-up is never new, a
82
+ call given no device is not tracked, and removing a key forgets every
83
+ device it signed. Breaking for a `switch` that exhausts `UserEventType`,
84
+ and for a `SignedIn` built by hand. See [devices](guide/devices.md).
73
85
  - **The first sign-in by link or code ends a squatter's password and second
74
86
  factor, v0.16.0** — when `signInCode.confirm` or `magicLink.confirm`
75
87
  proves an e-mail never verified, the password and the second factor with
@@ -152,11 +164,3 @@ before is in the [CHANGELOG](../CHANGELOG.md).
152
164
  Maizzle when the package is built, and only filled in at send time, every
153
165
  value escaped: no template engine in your server. A transport that fails
154
166
  throws, like a store.
155
- - **A permission id no store can keep is held by nobody** — an object or
156
- subject id holding a NUL character or a lone surrogate answers `false` from
157
- `can()` and an empty page from `list()`, before any store call, and a
158
- `fromField` holding one names nobody, nor does a `lookup` answering one;
159
- `grant()` and `revoke()` refuse it with a `TypeError` rather than
160
- `STORE_FAILED` on PostgreSQL alone. The relation store suite holds every
161
- adapter to reading back every other character in an id exactly as written.
162
- — v0.8.3
@@ -39,6 +39,7 @@ How the messages are shaped:
39
39
  - [`janus: secondFactor.issuer must name your application …`](#janus-secondfactorissuer-must-name-your-application--the-authenticator-app-shows-it-beside-the-account)
40
40
  - [`janus: secondFactor.keys: the key "<id>" is not 32 bytes in base64 …`](#janus-secondfactorkeys-the-key-id-is-not-32-bytes-in-base64--make-one-with-openssl-rand--base64-32)
41
41
  - [`"hasSecondFactor" is a field janus sets itself; rename it`](#hassecondfactor-is-a-field-janus-sets-itself-rename-it)
42
+ - [`janus: devices.keys: …`](#janus-deviceskeys-expected-at-least-one-key---id-key--the-first-seals)
42
43
  - [`janus: events must be a function that takes a user event …`](#janus-events-must-be-a-function-that-takes-a-user-event--webhooks---from-nxgtjanus-webhooks-or-your-own)
43
44
  - [`janus: signIn.throttle …` wiring messages](#janus-signinthrottle-wiring-messages)
44
45
  - [Other `janus:` wiring messages](#other-janus-wiring-messages)
@@ -97,7 +98,7 @@ How the messages are shaped:
97
98
  - [The link signed in another device than the one that asked](#the-link-signed-in-another-device-than-the-one-that-asked)
98
99
  - [`magicLink.request` answers `null` for a user who exists](#magiclinkrequest-answers-null-for-a-user-who-exists)
99
100
  - [`TS2339: Property 'magicLink' does not exist on type 'TypeApi<…>'.`](#ts2339-property-magiclink-does-not-exist-on-type-typeapi)
100
- - [`TS2554: Expected 1 arguments, but got 2.`, on `magicLink.confirm`](#ts2554-expected-1-arguments-but-got-2-on-magiclinkconfirm)
101
+ - [`TS2559: Type 'string' has no properties in common with type 'SignInOptions'.`, on `magicLink.confirm`](#ts2559-type-string-has-no-properties-in-common-with-type-signinoptions-on-magiclinkconfirm)
101
102
  - `TOKEN_UNKNOWN`, `TOKEN_SPENT`, `TOKEN_EXPIRED`, `TOKEN_STALE`, `USER_INACTIVE` and `VERSION_CONFLICT` from `magicLink.confirm`, and `TS2339` on its `token`: in their entries above.
102
103
 
103
104
  **Step-up**
@@ -111,6 +112,18 @@ How the messages are shaped:
111
112
  - [`TS2339: Property 'code' does not exist on type 'StepUpByEmail<…> | StepUpByApp<…>'.`](#ts2339-property-code-does-not-exist-on-type-stepupbyemail--stepupbyapp)
112
113
  - [`TS2339: Property 'stepUp' does not exist on type 'TypeApi<…>'.`](#ts2339-property-stepup-does-not-exist-on-type-typeapi)
113
114
 
115
+ **Devices**
116
+ - [`<call>: a device was given, but janus() has no devices — pass devices: { keys }`](#call-a-device-was-given-but-janus-has-no-devices--pass-devices--keys-)
117
+ - [`<call>: options must be { device }, or left out — the token goes under device`](#call-options-must-be--device--or-left-out--the-token-goes-under-device)
118
+ - [`<call>: options.device must be the device token the client holds, or null when it holds none`](#call-optionsdevice-must-be-the-device-token-the-client-holds-or-null-when-it-holds-none)
119
+ - [Every sign-in reports a new device](#every-sign-in-reports-a-new-device)
120
+ - [A new device is never reported: `newDevice` is always `false`](#a-new-device-is-never-reported-newdevice-is-always-false)
121
+ - [`TS2379: Argument of type '{ device: string | undefined; }' is not assignable to parameter of type 'SignInOptions' …`](#ts2379-argument-of-type--device-string--undefined--is-not-assignable-to-parameter-of-type-signinoptions-with-exactoptionalpropertytypes-true)
122
+ - [`TS2739: Type '{ … }' is missing the following properties from type 'SignedIn<…>': newDevice, deviceToken`](#ts2739-type----is-missing-the-following-properties-from-type-signedin-newdevice-devicetoken)
123
+ - [`TS2559: Type 'string' has no properties in common with type 'SignInOptions'.`, on `secondFactor.confirm` or `signInCode.confirm`](#ts2559-type-string-has-no-properties-in-common-with-type-signinoptions-on-secondfactorconfirm-or-signincodeconfirm)
124
+ - [`TS2322: Type 'string | null' is not assignable to type 'string'.`, on `deviceToken`](#ts2322-type-string--null-is-not-assignable-to-type-string-on-devicetoken)
125
+ - `janus: devices.keys: …`: under [Configuring `janus()`](#configuring-janus).
126
+
114
127
  **User events**
115
128
  - [`[JANUS_EVENT_FAILED] Warning: janus: the events listener failed on <type> <event id> for user <user id>: <name>`](#janus_event_failed-warning-janus-the-events-listener-failed-on-type-event-id-for-user-user-id-name)
116
129
  - [An event you expected never arrived](#an-event-you-expected-never-arrived)
@@ -395,6 +408,33 @@ janus({
395
408
  });
396
409
  ```
397
410
 
411
+ ### `janus: devices.keys: expected at least one key — [{ id, key }], the first seals`
412
+
413
+ Also:
414
+
415
+ - `janus: devices.keys: every key needs an id of letters, digits, _ and -, at most 64 of them`
416
+ - `janus: devices.keys: two keys have the id "<id>"`
417
+ - `janus: devices.keys: the key "<id>" is not 32 bytes in base64 — make one with openssl rand -base64 32`
418
+
419
+ **When:** `janus({ ..., devices })`.
420
+ **Why:** device tokens are signed with these keys, in the format of
421
+ `secondFactor.keys`: a list of at least one `{ id, key }`, the key 32 random
422
+ bytes in base64. An empty list, a key from an environment variable that is
423
+ not set, a hex key or a passphrase is refused.
424
+ **Fix:** make a key of its own — not a sealing key of the second factor's —
425
+ and keep it with your other secrets:
426
+
427
+ ```sh
428
+ openssl rand -base64 32
429
+ ```
430
+
431
+ ```ts
432
+ janus({
433
+ ...,
434
+ devices: { keys: [{ id: '2026-09', key: process.env.DEVICES_KEY_2026_09 ?? '' }] }, // the first signs
435
+ });
436
+ ```
437
+
398
438
  ### `"hasSecondFactor" is a field janus sets itself; rename it`
399
439
 
400
440
  **When:** `tsc`, on the `janus({...})` call, when your schema declares a `hasSecondFactor` field. This version added it to the fields janus sets on every user.
@@ -1289,12 +1329,17 @@ Or `@nxgt/janus` is older than 0.15, which brought `magicLink`.
1289
1329
  **Fix:** name the field that holds the e-mail with the type's `email`
1290
1330
  option, or upgrade `@nxgt/janus`.
1291
1331
 
1292
- ### `TS2554: Expected 1 arguments, but got 2.`, on `magicLink.confirm`
1332
+ ### `TS2559: Type 'string' has no properties in common with type 'SignInOptions'.`, on `magicLink.confirm`
1333
+
1334
+ Before 0.17: `TS2554: Expected 1 arguments, but got 2.`
1293
1335
 
1294
1336
  **When:** `tsc`, on `magicLink.confirm(challenge, code)` — a sign-in code's
1295
1337
  route copied for a link.
1296
- **Why:** a link carries no code: its token is the whole secret.
1297
- **Fix:** `magicLink.confirm(token)`, with the token the link's page posted.
1338
+ **Why:** a link carries no code: its token is the whole secret. The second
1339
+ argument is `SignInOptions`, `{ device }`, which a string is not.
1340
+ **Fix:** `magicLink.confirm(token)`, with the token the link's page posted —
1341
+ and `magicLink.confirm(token, { device: cookie ?? null })` with
1342
+ [devices](guide/devices.md).
1298
1343
 
1299
1344
  ---
1300
1345
 
@@ -1432,6 +1477,164 @@ a code has nowhere to be sent.
1432
1477
 
1433
1478
  ---
1434
1479
 
1480
+ ## Devices
1481
+
1482
+ The three messages below are bare `TypeError`s, thrown by a sign-in before
1483
+ anything is written — a `signUp` creates no user. `<call>` is the call you
1484
+ wrote: `signIn`, `secondFactor.recover`, `patient.magicLink.confirm`. A
1485
+ device token itself is never refused: one that proves nothing is a new
1486
+ device. [The devices guide](guide/devices.md) has the whole flow.
1487
+
1488
+ ### `<call>: a device was given, but janus() has no devices — pass devices: { keys }`
1489
+
1490
+ **When:** a sign-in given `{ device }` — `{ device: null }` included — on a
1491
+ `janus()` wired without `devices`. Most often one instance of several: the
1492
+ one that serves another route — the second factor's code, a sign-in link —
1493
+ was built from another configuration.
1494
+ **Why:** a device token is signed and checked with `devices.keys`; without
1495
+ keys there is nothing to sign it with, and answering `newDevice: false` would
1496
+ hide a notice you meant to send.
1497
+ **Fix:** give every `janus()` that signs users in the same `devices` — build
1498
+ the configuration once and import it:
1499
+
1500
+ ```ts
1501
+ export const devices = { keys: [{ id: '2026-09', key: process.env.DEVICES_KEY_2026_09 ?? '' }] } as const;
1502
+ janus({ ...config, devices });
1503
+ ```
1504
+
1505
+ Or stop passing `{ device }` where devices are not wanted.
1506
+
1507
+ ### `<call>: options must be { device }, or left out — the token goes under device`
1508
+
1509
+ **When:** from JavaScript, or through a cast, options that are not an
1510
+ object — most often the token itself, `auth.signIn(credentials, deviceOf(c))`,
1511
+ where `{ device: deviceOf(c) }` belongs. Left unrefused, that sign-in would
1512
+ be silently untracked: no token, no notice.
1513
+ **Why:** the last argument is `SignInOptions`, `{ device }`, or nothing.
1514
+ **Fix:**
1515
+
1516
+ ```ts
1517
+ await auth.signIn(credentials, { device: token ?? null });
1518
+ ```
1519
+
1520
+ ### `<call>: options.device must be the device token the client holds, or null when it holds none`
1521
+
1522
+ **When:** from JavaScript, or through a cast, a `device` that is neither a
1523
+ string nor `null`: a number, an object — the cookie jar, a parsed cookie, the
1524
+ previous `SignedIn` instead of its `deviceToken`. `undefined` is not refused:
1525
+ it means "not tracked".
1526
+ **Why:** `device` is the token the client holds, as a string, or `null` when
1527
+ it holds none.
1528
+ **Fix:**
1529
+
1530
+ ```ts
1531
+ await auth.signIn(credentials, { device: previous.deviceToken }); // the string, or null
1532
+ ```
1533
+
1534
+ ### Every sign-in reports a new device
1535
+
1536
+ **When:** `newDevice` is `true` — and `user.newDeviceSignedIn` sent, and a
1537
+ notice e-mailed — at every sign-in of a user who always uses the same
1538
+ browser.
1539
+ **Why:** the token the sign-in is given does not prove this user signed in
1540
+ there. In order of likelihood:
1541
+
1542
+ - **the device cookie is not kept.** It was never set from `deviceToken`;
1543
+ it was set without `Max-Age` or `Expires`, so the browser drops it when it
1544
+ closes; it is `Secure` on a plain `http://` origin other than
1545
+ `localhost`; its `Path` or `Domain` leaves out a sign-in route; or it is
1546
+ written under one name and read under another;
1547
+ - **a confirmation is given `null`** rather than the cookie — a
1548
+ `{ device: null }` written in the second factor's route — so each
1549
+ confirmation is a device holding no token;
1550
+ - **a device key was removed**, or two instances hold different keys: each
1551
+ device is new once after a removal, and at every sign-in that reaches the
1552
+ other instance;
1553
+ - **two people share the browser** and take turns: one cookie holds one
1554
+ user's token, and the other's proves nothing.
1555
+
1556
+ **Fix:** set the cookie from every answer's `deviceToken`, long-lived and
1557
+ sent to every sign-in route, and read it under the same name:
1558
+
1559
+ ```ts
1560
+ headers.append('Set-Cookie', `device=${signedIn.deviceToken}; Max-Age=34560000; Path=/; HttpOnly; Secure; SameSite=Lax`);
1561
+ ```
1562
+
1563
+ Give the confirmations the cookie too, and every `janus()` the same
1564
+ `devices`. The shared browser is not a bug: it is the price of storing
1565
+ nothing.
1566
+
1567
+ ### A new device is never reported: `newDevice` is always `false`
1568
+
1569
+ **When:** a user signs in from a browser that never saw them, and nothing is
1570
+ reported: `newDevice: false`, `deviceToken: null`, no event.
1571
+ **Why:**
1572
+
1573
+ - **no `device` was given** — absent, or `undefined`: a cookie read as
1574
+ `string | undefined` passed as it is, which compiles unless your tsconfig
1575
+ has `exactOptionalPropertyTypes`. Absent means *not tracked*, not *new*;
1576
+ - **the device was not given again** to `secondFactor.confirm` or `recover`:
1577
+ for a user with an active second factor, the challenge `signIn` answered
1578
+ carries none, and the confirmation decides alone;
1579
+ - `newDevice` read on `signUp`'s answer, which is never new.
1580
+
1581
+ **Fix:** pass `null` for a client with no token, and the device to every
1582
+ call that opens a session:
1583
+
1584
+ ```ts
1585
+ const device = cookie ?? null;
1586
+ const result = await auth.signIn(credentials, { device });
1587
+ if (result.status === 'secondFactor') await auth.secondFactor.confirm(result.challenge, code, { device });
1588
+ ```
1589
+
1590
+ ### `TS2379: Argument of type '{ device: string | undefined; }' is not assignable to parameter of type 'SignInOptions' with 'exactOptionalPropertyTypes: true'.`
1591
+
1592
+ It goes on: `Type 'string | undefined' is not assignable to type 'string | null'.`
1593
+
1594
+ **When:** `tsc`, with `exactOptionalPropertyTypes`, on a sign-in given a
1595
+ cookie read as `string | undefined`.
1596
+ **Why:** `undefined` would mean *not tracked*: a browser without a cookie
1597
+ would never be given a token, and never reported. `null` says the client
1598
+ holds none.
1599
+ **Fix:** `{ device: cookie ?? null }`. Without that compiler option, the same
1600
+ line compiles and tracks nothing — write `?? null` there too.
1601
+
1602
+ ### `TS2739: Type '{ … }' is missing the following properties from type 'SignedIn<…>': newDevice, deviceToken`
1603
+
1604
+ **When:** `tsc`, on a `SignedIn` built by hand — a test double of `signIn`,
1605
+ a stub of `auth` — after upgrading to 0.17.
1606
+ **Why:** every answer that opens a session now carries `newDevice` and
1607
+ `deviceToken`.
1608
+ **Fix:**
1609
+
1610
+ ```ts
1611
+ const signedIn: SignedIn<User> = { status: 'signedIn', user, session, token, newDevice: false, deviceToken: null };
1612
+ ```
1613
+
1614
+ ### `TS2559: Type 'string' has no properties in common with type 'SignInOptions'.`, on `secondFactor.confirm` or `signInCode.confirm`
1615
+
1616
+ Also: `Type '"<value>"' has no properties in common with type 'SignInOptions'.`
1617
+ for a literal token, and `TS2345: Argument of type 'string | undefined' is
1618
+ not assignable to parameter of type 'SignInOptions | undefined'.` — or
1619
+ `'string | null'` — for a cookie or an earlier `deviceToken`, given as is.
1620
+
1621
+ **When:** `tsc`, on `secondFactor.confirm(challenge, code, token)` or
1622
+ `signInCode.confirm(challenge, code, token)` — the device token given as a
1623
+ bare string, or as a nullable one.
1624
+ **Why:** the options come last, as an object: `{ device }`.
1625
+ **Fix:** `secondFactor.confirm(challenge, code, { device: token ?? null })`.
1626
+
1627
+ ### `TS2322: Type 'string | null' is not assignable to type 'string'.`, on `deviceToken`
1628
+
1629
+ **When:** `tsc`, reading `signedIn.deviceToken` as a `string` — to set a
1630
+ cookie.
1631
+ **Why:** it is `null` when the sign-in was given no `device`: nothing was
1632
+ minted then.
1633
+ **Fix:** set the cookie only for a token — `if (signedIn.deviceToken !==
1634
+ null) …` — or give every sign-in a `device`.
1635
+
1636
+ ---
1637
+
1435
1638
  ## User events
1436
1639
 
1437
1640
  ### `[JANUS_EVENT_FAILED] Warning: janus: the events listener failed on <type> <event id> for user <user id>: <name>`
@@ -1454,6 +1657,9 @@ process.on('warning', (warning) => {
1454
1657
  **Why:** one of these, in order of likelihood:
1455
1658
  - the e-mail was already verified: nothing changed, so nothing is sent;
1456
1659
  - `delete` deleted nobody — a replay, or an id of another user type;
1660
+ - a `user.newDeviceSignedIn` for a sign-in given no `device`, from a known
1661
+ device, or confirmed without the device given again — see
1662
+ [A new device is never reported](#a-new-device-is-never-reported-newdevice-is-always-false);
1457
1663
  - the flow was refused, or the store failed during the write itself — the call threw, and nothing was written or sent;
1458
1664
  - the process stopped between the write and the listener — events are sent at most once, from memory;
1459
1665
  - the listener threw: look for `JANUS_EVENT_FAILED` in the process's warnings.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxgt/janus",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "Embeddable, type-safe identities and permissions: bring your own database",
5
5
  "license": "MIT",
6
6
  "type": "module",