@nxgt/janus 0.4.0 → 0.5.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 (55) hide show
  1. package/README.md +102 -18
  2. package/dist/auth/config.d.ts +25 -1
  3. package/dist/auth/config.d.ts.map +1 -1
  4. package/dist/auth/context.d.ts.map +1 -1
  5. package/dist/auth/index.d.ts +3 -2
  6. package/dist/auth/index.d.ts.map +1 -1
  7. package/dist/auth/one-time.d.ts +39 -0
  8. package/dist/auth/one-time.d.ts.map +1 -0
  9. package/dist/auth/port/types.d.ts +10 -7
  10. package/dist/auth/port/types.d.ts.map +1 -1
  11. package/dist/auth/sealing.d.ts +39 -0
  12. package/dist/auth/sealing.d.ts.map +1 -0
  13. package/dist/auth/second-factor/challenge.d.ts +20 -0
  14. package/dist/auth/second-factor/challenge.d.ts.map +1 -0
  15. package/dist/auth/second-factor/factor.d.ts +30 -0
  16. package/dist/auth/second-factor/factor.d.ts.map +1 -0
  17. package/dist/auth/second-factor/flows.d.ts +19 -0
  18. package/dist/auth/second-factor/flows.d.ts.map +1 -0
  19. package/dist/auth/second-factor/lifecycle.d.ts +8 -0
  20. package/dist/auth/second-factor/lifecycle.d.ts.map +1 -0
  21. package/dist/auth/sessions.d.ts.map +1 -1
  22. package/dist/auth/totp.d.ts +36 -0
  23. package/dist/auth/totp.d.ts.map +1 -0
  24. package/dist/auth/types.d.ts +91 -7
  25. package/dist/auth/types.d.ts.map +1 -1
  26. package/dist/auth/users.d.ts +2 -2
  27. package/dist/auth/users.d.ts.map +1 -1
  28. package/dist/chunks/{index-qwfkhqkk.js → index-06vp9c5r.js} +12 -3
  29. package/dist/chunks/{index-qwfkhqkk.js.map → index-06vp9c5r.js.map} +3 -3
  30. package/dist/chunks/{index-53y1afjz.js → index-5vr13kkb.js} +2 -2
  31. package/dist/chunks/{index-mgh85djb.js → index-c4v27jfr.js} +2 -2
  32. package/dist/chunks/{index-thtyq7a9.js → index-gbwn2tts.js} +2 -2
  33. package/dist/conformance/cases/tokens.d.ts.map +1 -1
  34. package/dist/conformance/index.js +15 -4
  35. package/dist/conformance/index.js.map +3 -3
  36. package/dist/errors/janus-error.d.ts +28 -2
  37. package/dist/errors/janus-error.d.ts.map +1 -1
  38. package/dist/index.d.ts +1 -1
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +388 -41
  41. package/dist/index.js.map +14 -7
  42. package/dist/permissions/index.js +3 -3
  43. package/docs/README.md +1 -0
  44. package/docs/guide/adapters.md +12 -7
  45. package/docs/guide/errors.md +18 -6
  46. package/docs/guide/second-factor.md +569 -0
  47. package/docs/guide/sessions.md +18 -0
  48. package/docs/guide/users.md +8 -2
  49. package/docs/guide/vocabulary.md +6 -3
  50. package/docs/roadmap.md +26 -48
  51. package/docs/troubleshooting.md +260 -5
  52. package/package.json +1 -1
  53. /package/dist/chunks/{index-53y1afjz.js.map → index-5vr13kkb.js.map} +0 -0
  54. /package/dist/chunks/{index-mgh85djb.js.map → index-c4v27jfr.js.map} +0 -0
  55. /package/dist/chunks/{index-thtyq7a9.js.map → index-gbwn2tts.js.map} +0 -0
package/docs/roadmap.md CHANGED
@@ -7,14 +7,15 @@ dates here, and the version something shipped in is the only number.
7
7
 
8
8
  - **One-time codes** — a one-time token short enough to type, sent by e-mail
9
9
  to sign in without a password, or to confirm a sensitive action, issued
10
- and redeemed by `janus` like the verification and reset tokens today; and
11
- TOTP, the one-time codes of an authenticator app, as a second factor, its
12
- secret sealed with a key your application holds. `signIn` will answer
13
- `{ status: 'signedIn' }` or `{ status: 'secondFactor', challenge }`. The
14
- store port that holds them shipped in v0.4.0, below.
10
+ and redeemed by `janus` like the verification and reset tokens today. The
11
+ TOTP second factor shipped in v0.5.0, below, on the store port that
12
+ shipped in v0.4.0.
15
13
 
16
14
  ## Next
17
15
 
16
+ - **Recovery codes** — single-use codes for the TOTP second factor, so a
17
+ user who loses their authenticator app can still sign in, without an
18
+ operator resetting the account.
18
19
  - **Sending the e-mails** — in a package of its own, `@nxgt/janus-mail`, built
19
20
  on a general mail toolkit shared with applications that are not about
20
21
  sign-in: a `Mailer` port you plug your transport into (SMTP, Resend, SES…) —
@@ -44,8 +45,10 @@ dates here, and the version something shipped in is the only number.
44
45
  - **More official adapters** — the ports are cut where atomicity is not
45
46
  required, so users, sessions and permission tuples can each live in the
46
47
  database that suits them. MongoDB is the first adapter
47
- ([`@nxgt/janus-mongo`](https://www.npmjs.com/package/@nxgt/janus-mongo));
48
- PostgreSQL is under Now.
48
+ ([`@nxgt/janus-mongo`](https://www.npmjs.com/package/@nxgt/janus-mongo)),
49
+ PostgreSQL and Redis followed
50
+ ([`@nxgt/janus-drizzle`](https://www.npmjs.com/package/@nxgt/janus-drizzle),
51
+ [`@nxgt/janus-redis`](https://www.npmjs.com/package/@nxgt/janus-redis)).
49
52
 
50
53
  ## Not planned
51
54
 
@@ -84,14 +87,23 @@ dates here, and the version something shipped in is the only number.
84
87
 
85
88
  ## Shipped
86
89
 
87
- Each entry names the version it came in.
90
+ The last ten, newest first, each with the version it came in. Everything
91
+ before is in the [CHANGELOG](../CHANGELOG.md).
88
92
 
93
+ - **A TOTP second factor, v0.5.0** — `janus({ secondFactor: { issuer, keys } })`
94
+ and `auth.<type>.secondFactor`'s `enroll`, `activate`, `disable` and
95
+ `confirm`, for every user type with a password; each secret sealed with
96
+ AES-256-GCM under keys your application holds, and rotated by adding a key.
97
+ Breaking once configured: `signIn` answers `{ status: 'signedIn', … }` or
98
+ `{ status: 'secondFactor', challenge, expiresAt }`. A challenge lives five
99
+ minutes and takes five attempts, a code is accepted once, and a refused one
100
+ throws `CODE_INVALID` with `attemptsLeft`.
89
101
  - **The store port holds a second factor and counts attempts on a token,
90
102
  v0.4.0** — `UserRecord.secondFactor`, a token's `codeHash` and `attempts`,
91
103
  the token kinds `'secondFactor'` and `'signInCode'`, and
92
104
  `TokenStore.countAttempt`, one conditional write per attempt, with six new
93
- conformance cases. No flow uses them yet; the published adapters implement
94
- them in `@nxgt/janus-drizzle` 0.2, `@nxgt/janus-mongo` 0.3 and
105
+ conformance cases. The published adapters implement them in
106
+ `@nxgt/janus-drizzle` 0.2, `@nxgt/janus-mongo` 0.3 and
95
107
  `@nxgt/janus-redis` 0.2.
96
108
  - **Permissions on a user, v0.3.0** — a user type may also be an object type:
97
109
  a staff member is the object `can()` asks about and is granted relations
@@ -109,7 +121,10 @@ Each entry names the version it came in.
109
121
  audit trail, never a login, a password, a session token or a one-time token;
110
122
  and [`@nxgt/janus-kit`](https://www.npmjs.com/package/@nxgt/janus-kit), all
111
123
  of it wired in one call.
112
-
124
+ - **A NUL character or a lone surrogate never reaches a store** — refused in
125
+ fields with `USER_INVALID` on every adapter, rather than `STORE_FAILED` on
126
+ PostgreSQL alone; a login holding one is nobody's. The conformance suite
127
+ holds every adapter to round-tripping every other character. — v0.2.1
113
128
  - **The model's keys read `related` and `permits`** — Keto's OPL words: an
114
129
  object type declares `related: { members: ['staff', 'team#members'] }` and
115
130
  `permits: { view: ['members'] }`, and relation names are plural by convention. Breaking:
@@ -123,11 +138,6 @@ Each entry names the version it came in.
123
138
  - **The conformance suite accepts a store with its own expiry** — a store
124
139
  that drops a lapsed session at once, as a Redis TTL does, passes
125
140
  `sessions.deleteUser`; one that still holds it must count it. — v0.2.0
126
- - **A NUL character or a lone surrogate never reaches a store** — refused in
127
- fields with `USER_INVALID` on every adapter, rather than `STORE_FAILED` on
128
- PostgreSQL alone; a login holding one is nobody's. The conformance suite
129
- holds every adapter to round-tripping every other character. — v0.2.1
130
-
131
141
  - **A Hono integration** — [`@nxgt/janus-hono`](https://www.npmjs.com/package/@nxgt/janus-hono):
132
142
  the session middleware, the cookie, a route guarded by a permission,
133
143
  `bindJanus()` to bind the instances once, and every error as its status.
@@ -136,35 +146,3 @@ Each entry names the version it came in.
136
146
  in a relation, subject types in `fromField`, relations, permissions and
137
147
  arrows in a rule and in `when`; a wrong name's error lists the names it
138
148
  could have been. — v0.1.2
139
-
140
- - **The model decides what a stored tuple grants** — `can()` and `list()` follow
141
- only the holders a relation admits, as `grant()` writes only those: a tuple
142
- stored past `grant()`, by an older model or by hand, grants nothing. — v0.1
143
- - **Guides and troubleshooting pages** — a `docs/` folder shipped in the
144
- package: detailed guides with examples, and the errors you can meet, each
145
- with its cause and fix. — v0.1
146
- - **Permissions at `@nxgt/janus/permissions`** — and `janus({ relations })`,
147
- so deleting a user also deletes every tuple naming them. — v0.1
148
- - **`list()`** — the ids of every object a subject holds a permission on, as a
149
- cursor page, `fromField` relations included through their `lookup`. — v0.1
150
- - **`can()`, `grant()` and `revoke()`** — a permission check that answers
151
- `true` or `false` and throws on an outage, and tuple writes refused at
152
- compile time when the model does not admit them. — v0.1
153
- - **Typed subjects and the `RelationStore` port** — `{ type, id }` subjects,
154
- the tuple notation, `createMemoryRelations()`, and
155
- `describeRelationStores` for adapter authors. — v0.1
156
- - **A permission model typed from itself** — `defineModel` with subject sets,
157
- arrows, `fromField` relations read from your data, and `when` conditions
158
- written in TypeScript. — v0.1
159
- - **Delete a user, and everything of theirs** — `delete(user)` removes the
160
- user with every session and one-time token they had, idempotently. — v0.1
161
- - **Rehash a stale password on sign-in** — moving hashers, or raising a cost,
162
- reaches every active user with no migration to run. — v0.1
163
- - **`janus()`** — sign-up, sign-in, sessions, e-mail verification and password
164
- reset, with several user types in one instance, typed from your schemas.
165
- — v0.1
166
- - **The conformance suite** — `@nxgt/janus/conformance`, the suite an adapter
167
- runs, outages included. — v0.1
168
- - **The identity stores' port and its in-memory reference** — `JanusStores` and
169
- `createMemoryStores()`, for your tests and as the model for an adapter.
170
- — v0.1
@@ -23,7 +23,7 @@ How the messages are shaped:
23
23
  - [`error instanceof StoreFailure` is `false` for an outage](#error-instanceof-storefailure-is-false-for-an-outage)
24
24
 
25
25
  **Configuring `janus()`**
26
- - [`janus: pass either user … or users …, and exactly one of them`](#janus-pass-either-user-one-kind-of-user-or-users-several-kinds-and-exactly-one-of-them)
26
+ - [`janus: pass either user … or users …, and exactly one of them`](#janus-pass-either-user-one-user-type-or-users-several-user-types-and-exactly-one-of-them)
27
27
  - [`janus: user must be a Standard Schema …`](#janus-user-must-be-a-standard-schema--a-zod-4-valibot-or-arktype-schema)
28
28
  - [`janus: a user type signs in with a password and no hasher is wired …`](#janus-a-user-type-signs-in-with-a-password-and-no-hasher-is-wired--pass-hasher-scrypthasher-or-bunhasher-on-bun-there-is-no-silent-fallback)
29
29
  - [`bunHasher: Bun.password is not available …`](#bunhasher-bunpassword-is-not-available--this-runtime-is-not-bun-wire-scrypthasher-instead)
@@ -36,6 +36,9 @@ How the messages are shaped:
36
36
  - [`janus: the user type "<name>" must be a camelCase name …`](#janus-the-user-type-name-must-be-a-camelcase-name--letters-and-digits-starting-with-a-letter)
37
37
  - [`janus: session.lifespan: "<value>" is not a duration …`](#janus-sessionlifespan-value-is-not-a-duration-write-a-number-followed-by-ms-s-m-h-or-d--for-example-15m-or-720h)
38
38
  - [`janus: cookie.sameSite "none" requires cookie.secure …`](#janus-cookiesamesite-none-requires-cookiesecure--browsers-refuse-the-cookie-otherwise)
39
+ - [`janus: secondFactor.issuer must name your application …`](#janus-secondfactorissuer-must-name-your-application--the-authenticator-app-shows-it-beside-the-account)
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
+ - [`"hasSecondFactor" is a field janus sets itself; rename it`](#hassecondfactor-is-a-field-janus-sets-itself-rename-it)
39
42
  - [Other `janus:` wiring messages](#other-janus-wiring-messages)
40
43
 
41
44
  **Users, sessions and tokens**
@@ -58,6 +61,19 @@ How the messages are shaped:
58
61
  - [`<call>: the <type> type does not sign in with a password …`](#call-the-type-type-does-not-sign-in-with-a-password--add-password--login--to-it)
59
62
  - [`authenticate()` answers `null` although a valid cookie was sent](#authenticate-answers-null-although-a-valid-cookie-was-sent)
60
63
 
64
+ **Second factor**
65
+ - [`TS2339: Property 'token' does not exist on type 'SignInResult<…>'.`](#ts2339-property-token-does-not-exist-on-type-signinresult)
66
+ - [`CODE_INVALID` — `<call>: the code does not match, or was already used`](#code_invalid--call-the-code-does-not-match-or-was-already-used)
67
+ - [The code the authenticator app shows is refused with `CODE_INVALID`](#the-code-the-authenticator-app-shows-is-refused-with-code_invalid)
68
+ - [`SECOND_FACTOR_NOT_ENROLLED` — `secondFactor.activate: the user has no second factor waiting …`](#second_factor_not_enrolled--secondfactoractivate-the-user-has-no-second-factor-waiting--call-enroll-first)
69
+ - [`SECOND_FACTOR_ACTIVE` — `secondFactor.enroll: the user's second factor is active …`](#second_factor_active--secondfactorenroll-the-users-second-factor-is-active--disable-it-first)
70
+ - [`<call>: …, and janus() was given no secondFactor …`](#call--and-janus-was-given-no-secondfactor--pass-secondfactor--issuer-keys-)
71
+ - [`<call>: the secret is sealed with the key "<id>", which secondFactor.keys no longer holds …`](#call-the-secret-is-sealed-with-the-key-id-which-secondfactorkeys-no-longer-holds--keep-a-key-until-no-secret-is-sealed-with-it)
72
+ - [`<call>: the secret does not open with the key "<id>" — was that key changed under the same id, or the secret copied from another user?`](#call-the-secret-does-not-open-with-the-key-id--was-that-key-changed-under-the-same-id-or-the-secret-copied-from-another-user)
73
+ - [`<call>: the stored secret is not a sealed one`](#call-the-stored-secret-is-not-a-sealed-one)
74
+ - [`<call>: the <type> type does not sign in with a password, so it has no second factor`](#call-the-type-type-does-not-sign-in-with-a-password-so-it-has-no-second-factor)
75
+ - `TOKEN_*`, `USER_INACTIVE` and `VERSION_CONFLICT` from `secondFactor.confirm`: in their entries above.
76
+
61
77
  **Permissions**
62
78
  - [`PERMISSION_DEPTH` — `can: checking <type>#<permission> crossed more than <n> relations without an answer`](#permission_depth--can-checking-typepermission-crossed-more-than-n-relations-without-an-answer)
63
79
  - [`permissions: this model was not made by defineModel() …`](#permissions-this-model-was-not-made-by-definemodel--pass-what-definemodel-answered)
@@ -73,6 +89,7 @@ How the messages are shaped:
73
89
 
74
90
  **Subjects**
75
91
  - [`parseTuple: "<text>" is not a relation tuple; expected type:id#relation@subject`](#parsetuple-text-is-not-a-relation-tuple-expected-typeidrelationsubject)
92
+ - [`setOf: pass a user or { type, id }, then a relation`](#setof-pass-a-user-or--type-id--then-a-relation)
76
93
 
77
94
  **Conformance (adapter authors)**
78
95
  - [`describeJanusStores: no test runner on globalThis …`](#describejanusstores-no-test-runner-on-globalthis--pass-runner--describe-it--under-bun-test-import-them-from-buntest)
@@ -274,7 +291,7 @@ Also `janus: "<name>" cannot name a user type — janus() answers a method of th
274
291
 
275
292
  ### `janus: session.lifespan: "<value>" is not a duration; write a number followed by ms, s, m, h or d — for example "15m" or "720h"`
276
293
 
277
- The same for `session.renewAfter`, `tokens.verifyEmail` and `tokens.resetPassword`. Also `<option>: a duration must be above zero` and `<option>: a duration in milliseconds must be a finite number above zero`.
294
+ The same for `session.renewAfter`, `tokens.verifyEmail`, `tokens.resetPassword` and `secondFactor.challenge`. Also `<option>: a duration must be above zero` and `<option>: a duration in milliseconds must be a finite number above zero`.
278
295
 
279
296
  **When:** `janus({...})`.
280
297
  **Why:** a duration is a number of milliseconds, or a number followed by one unit. `'30 m'` compiles — TypeScript's `${number}` accepts the space — and is refused here.
@@ -292,6 +309,48 @@ Also `janus: cookie.name must be a cookie-name token — letters, digits and !#$
292
309
  **Why:** browsers drop a `SameSite=None` cookie that is not `Secure`, so every sign-in would silently fail to stick.
293
310
  **Fix:** keep `secure: true` with `sameSite: 'none'`, or use the default `sameSite: 'lax'` when the site and the API share a site.
294
311
 
312
+ ### `janus: secondFactor.issuer must name your application — the authenticator app shows it beside the account`
313
+
314
+ **When:** `janus({ ..., secondFactor })`, when `issuer` is missing, is not a string, or is blank.
315
+ **Why:** the issuer is the label the authenticator app shows beside the account. Without it, a user with several accounts in the app cannot tell which code is yours.
316
+ **Fix:**
317
+
318
+ ```ts
319
+ janus({ ..., secondFactor: { issuer: 'Acme', keys } });
320
+ ```
321
+
322
+ ### `janus: secondFactor.keys: the key "<id>" is not 32 bytes in base64 — make one with openssl rand -base64 32`
323
+
324
+ Also:
325
+
326
+ - `janus: secondFactor.keys: expected at least one key — [{ id, key }], the first seals`
327
+ - `janus: secondFactor.keys: every key needs an id of letters, digits, _ and -, at most 64 of them`
328
+ - `janus: secondFactor.keys: two keys have the id "<id>"`
329
+
330
+ **When:** `janus({ ..., secondFactor })`.
331
+ **Why:** every TOTP secret is sealed with AES-256-GCM before a store sees it, and that takes a 32-byte key: 43 characters of base64 or base64url, with or without the trailing `=`. A hex key (64 characters), a passphrase, or an environment variable that is not set is refused. The id is written into every secret the key seals, so it is short, plain, and names one key only.
332
+ **Fix:** make each key once and keep it with your other secrets:
333
+
334
+ ```sh
335
+ openssl rand -base64 32
336
+ ```
337
+
338
+ ```ts
339
+ janus({
340
+ ...,
341
+ secondFactor: {
342
+ issuer: 'Acme',
343
+ keys: [{ id: 'k2026a', key: process.env.TOTP_KEY_K2026A ?? '' }], // the first key seals
344
+ },
345
+ });
346
+ ```
347
+
348
+ ### `"hasSecondFactor" is a field janus sets itself; rename it`
349
+
350
+ **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.
351
+ **Why:** `user.hasSecondFactor` is janus's own answer: whether the user's second factor is active. A field of yours with that name would be shadowed. From JavaScript, a validated input holding it is refused with `USER_INVALID` and the issue `set by janus, not by a request`.
352
+ **Fix:** rename the field in your schema, and read `user.hasSecondFactor` for janus's answer.
353
+
295
354
  ### Other `janus:` wiring messages
296
355
 
297
356
  | Message | Fix |
@@ -365,6 +424,23 @@ const user = await auth.get(id);
365
424
  await auth.update(user, { name }, { ifVersion: user.version });
366
425
  ```
367
426
 
427
+ **From `secondFactor.confirm`**, the message is the store's:
428
+ `updateUser: expected version <n>, found <m>`.
429
+
430
+ **When:** two `confirm` calls for one user run at once with the same code — a
431
+ double-submitted form, two tabs, a client that retries before the first answer.
432
+ **Why:** of two codes accepted at once, the first write wins and opens a
433
+ session. The second finds the user's version moved and writes nothing: no
434
+ session. Its challenge is not spent, and has lost one attempt.
435
+ **Fix:** submit the code form once. The session already exists, from the call
436
+ that won; if the visitor still needs one, the next code works on the same
437
+ challenge — the same code will not, it was used.
438
+
439
+ ```ts
440
+ // in the browser: one submit per code
441
+ form.addEventListener('submit', () => form.querySelector('button')?.setAttribute('disabled', ''));
442
+ ```
443
+
368
444
  ### `USER_INVALID` — `<call>: the fields do not match the <type> schema (<n> issues, at <paths>)`
369
445
 
370
446
  `UserInvalidError`, carrying `issues` — each a `path` and a `message`.
@@ -416,9 +492,9 @@ janus({ ..., hasher: scryptHasher(), verifiers: [bcryptVerifier] }); // a Passwo
416
492
 
417
493
  ### `USER_INACTIVE` — `<call>: the user is inactive`
418
494
 
419
- **When:** `signIn`, with the **right** password, for a user set inactive.
420
- **Why:** an inactive user keeps their record and password, and every sign-in is refused. It is checked after the password, so only somebody who knows the password learns the user is inactive.
421
- **Fix:** answer 403, or reactivate: `await auth.setActive(user, true)`.
495
+ **When:** `signIn`, with the **right** password, for a user set inactive. Also `secondFactor.confirm`, for a user set inactive after `signIn` asked for a code.
496
+ **Why:** an inactive user keeps their record and password, and every sign-in is refused. It is checked after the password, so only somebody who knows the password learns the user is inactive. On `secondFactor.confirm`, the challenge is spent: reactivating the user does not revive it.
497
+ **Fix:** answer 403, or reactivate, then sign in again: `await auth.setActive(user, true)`.
422
498
 
423
499
  ### `TOKEN_UNKNOWN`, `TOKEN_SPENT`, `TOKEN_EXPIRED`
424
500
 
@@ -432,6 +508,24 @@ janus({ ..., hasher: scryptHasher(), verifiers: [bcryptVerifier] }); // a Passwo
432
508
  janus({ ..., tokens: { verifyEmail: '72h', resetPassword: '2h' } });
433
509
  ```
434
510
 
511
+ **On `secondFactor.confirm`**, the messages name the challenge `signIn`
512
+ answered: `secondFactor.confirm: no such challenge`,
513
+ `secondFactor.confirm: the challenge was already used`,
514
+ `secondFactor.confirm: the challenge has expired`.
515
+
516
+ **When:** `secondFactor.confirm(challenge, code)`.
517
+ **Why:** a challenge lives five minutes and takes five codes. It is spent by
518
+ the code that opens the session, by the fifth wrong code, and by a refusal
519
+ that ends it (`USER_INACTIVE`, `SECOND_FACTOR_NOT_ENROLLED`). `TOKEN_UNKNOWN`
520
+ also covers a challenge whose user was deleted, and a challenge passed where a
521
+ code was expected — the two arguments swapped.
522
+ **Fix:** answer 400 and send the visitor back to sign in, which asks for a new
523
+ code. To give slower visitors more time:
524
+
525
+ ```ts
526
+ janus({ ..., secondFactor: { issuer: 'Acme', keys, challenge: '10m' } });
527
+ ```
528
+
435
529
  ### `TOKEN_STALE` — `<call>: the token was sent to an e-mail the user no longer has`
436
530
 
437
531
  **When:** `verifyEmail.confirm` or `resetPassword.confirm`, after the user changed their e-mail.
@@ -486,6 +580,167 @@ const page = await access.list(user, 'view', 'record', {
486
580
 
487
581
  ---
488
582
 
583
+ ## Second factor
584
+
585
+ Every entry here needs `janus({ ..., secondFactor })`. The messages start with
586
+ `secondFactor.enroll`, `secondFactor.activate`, `secondFactor.confirm` or
587
+ `signIn` — prefixed by the type with several user types:
588
+ `staff.secondFactor.confirm: …`. `secondFactor.confirm` also rejects with
589
+ [`TOKEN_UNKNOWN`, `TOKEN_SPENT`, `TOKEN_EXPIRED`](#token_unknown-token_spent-token_expired),
590
+ [`USER_INACTIVE`](#user_inactive--call-the-user-is-inactive) and
591
+ [`VERSION_CONFLICT`](#version_conflict--call-expected-version-n-found-m);
592
+ each of those entries has a paragraph for it.
593
+
594
+ ### `TS2339: Property 'token' does not exist on type 'SignInResult<…>'.`
595
+
596
+ The same for `session` and `user`.
597
+
598
+ **When:** `tsc`, wherever `signIn`'s answer is read, once `janus()` is given a `secondFactor`.
599
+ **Why:** `signIn` then answers one of two shapes: `{ status: 'signedIn', user, session, token }`, or `{ status: 'secondFactor', challenge, expiresAt }` for a user whose second factor is active — the password alone opens no session for them. Without `secondFactor`, `signIn` still answers a session.
600
+ **Fix:** switch on `status`:
601
+
602
+ ```ts
603
+ const result = await auth.signIn({ email, password });
604
+ if (result.status === 'secondFactor') {
605
+ // Keep the challenge for the next request — never in a URL — and ask for a code.
606
+ return Response.json({ challenge: result.challenge, expiresAt: result.expiresAt });
607
+ }
608
+ return Response.json({ token: result.token });
609
+
610
+ // the next request, with the code the app shows
611
+ const signedIn = await auth.secondFactor.confirm(challenge, code); // { status: 'signedIn', token, … }
612
+ ```
613
+
614
+ ### `CODE_INVALID` — `<call>: the code does not match, or was already used`
615
+
616
+ `TokenError`, carrying `attemptsLeft` on `secondFactor.confirm`.
617
+
618
+ **When:** `secondFactor.activate(user, code)` or `secondFactor.confirm(challenge, code)`.
619
+ **Why:** the code is wrong, or it was accepted before: a code is accepted once. On `confirm`, every call costs one of the challenge's five attempts, counted before anything is checked. `attemptsLeft` is what remains; at `0`, the challenge is spent and the next `confirm` is `TOKEN_SPENT`. On `activate` there is no challenge, no `attemptsLeft`, and nothing is spent: the user tries the next code.
620
+ **Fix:** answer 401 with `attemptsLeft`, and send the visitor back to sign in once it is `0`:
621
+
622
+ ```ts
623
+ import { TokenError } from '@nxgt/janus';
624
+
625
+ try {
626
+ return await auth.secondFactor.confirm(challenge, code);
627
+ } catch (error) {
628
+ if (error instanceof TokenError && error.code === 'CODE_INVALID') {
629
+ return Response.json({ code: error.code, attemptsLeft: error.attemptsLeft }, { status: 401 });
630
+ }
631
+ throw error;
632
+ }
633
+ ```
634
+
635
+ If the code the user typed is the one their app shows, see the next entry.
636
+
637
+ ### The code the authenticator app shows is refused with `CODE_INVALID`
638
+
639
+ **When:** `activate` or `confirm`, with the code on screen, typed correctly.
640
+ **Why**, in the order to check:
641
+
642
+ 1. **A clock is off.** A code is accepted in its own 30-second step and one step either side. A phone or a server whose clock is more than about 30 seconds away from the real time produces codes outside those three steps. How many steps are accepted is not configurable.
643
+ 2. **The code was already used** — and so were the codes before it. Once a code is accepted, that code and every earlier one are refused: the user who activates and then signs in within the same 30 seconds, or who signs in twice in a row, must wait for the next code.
644
+ 3. **The app holds an older secret.** `enroll` called again before `activate` replaces the secret: an entry scanned from the first `enroll` shows codes for a secret nobody holds any more.
645
+
646
+ **Fix:** for 1, keep the server's clock synchronised and ask the user to set their phone's time automatically. For 2, wait for the next code. For 3, delete the entry from the app and scan the `uri` of the last `enroll`.
647
+
648
+ ```sh
649
+ timedatectl show -p NTPSynchronized # NTPSynchronized=yes on the server
650
+ ```
651
+
652
+ ### `SECOND_FACTOR_NOT_ENROLLED` — `secondFactor.activate: the user has no second factor waiting — call enroll first`
653
+
654
+ `SecondFactorError`. Also `secondFactor.confirm: the user no longer has a second factor — sign in again`.
655
+
656
+ **When:** `activate` before `enroll`, or after `disable`. On `confirm`: the factor was disabled after `signIn` asked for a code.
657
+ **Why:** `activate` checks a code against the secret `enroll` wrote, and there is none. On `confirm`, the factor the challenge asked for is gone, so the challenge is spent.
658
+ **Fix:** `enroll`, show the `uri` as a QR code, then `activate` with a code from the app. After `confirm`'s refusal, sign in again: `signIn` answers a session directly for a user without a factor.
659
+
660
+ ```ts
661
+ const { secret, uri } = await auth.secondFactor.enroll(user);
662
+ // …the user scans uri, or types secret…
663
+ await auth.secondFactor.activate(user, code);
664
+ ```
665
+
666
+ ### `SECOND_FACTOR_ACTIVE` — `secondFactor.enroll: the user's second factor is active — disable it first`
667
+
668
+ `SecondFactorError`. Also `secondFactor.activate: the user's second factor is already active`.
669
+
670
+ **When:** `enroll` or `activate` for a user whose factor is already active — often a form submitted twice, or a "set up again" button.
671
+ **Why:** enrolling again would replace the secret and quietly switch the factor off until the new one is activated. It is refused so that only `disable` switches it off.
672
+ **Fix:** check `user.hasSecondFactor` first. To move to a new phone, disable, then enroll:
673
+
674
+ ```ts
675
+ const cleared = await auth.secondFactor.disable(user);
676
+ const { uri } = await auth.secondFactor.enroll(cleared);
677
+ ```
678
+
679
+ ### `<call>: …, and janus() was given no secondFactor — pass secondFactor: { issuer, keys }`
680
+
681
+ Most often: `signIn: the user's second factor is active, and janus() was given no secondFactor — pass secondFactor: { issuer, keys }`. A `TypeError`.
682
+
683
+ **When:** `signIn` for a user whose factor is active, on a `janus()` built without `secondFactor` — a second process over the same users: a worker, a script, an admin service, an older deployment. From JavaScript, `enroll`, `activate` and `confirm` on such an instance too.
684
+ **Why:** the password alone never opens a session for a user with an active factor, and an instance without keys cannot check a code. It refuses rather than sign the user in on the password alone.
685
+ **Fix:** give every `janus()` over the same users the same `secondFactor`, from one module:
686
+
687
+ ```ts
688
+ // auth-config.ts, imported by every process
689
+ export const secondFactor = { issuer: 'Acme', keys } as const;
690
+
691
+ janus({ ..., secondFactor });
692
+ ```
693
+
694
+ ### `<call>: the secret is sealed with the key "<id>", which secondFactor.keys no longer holds — keep a key until no secret is sealed with it`
695
+
696
+ A `TypeError`.
697
+
698
+ **When:** `activate` or `confirm`, for a user whose secret was sealed with a key since removed from `keys` — or with a key another deployment has and this one does not.
699
+ **Why:** each sealed secret names its key. The first key seals, every key opens, and a secret is sealed again under the first key only the next time a code of that user is accepted. A user who has not signed in since the rotation still holds the old seal.
700
+ **Fix:** put the old key back, after the new one:
701
+
702
+ ```ts
703
+ secondFactor: {
704
+ issuer: 'Acme',
705
+ keys: [
706
+ { id: 'k2026b', key: process.env.TOTP_KEY_K2026B ?? '' }, // seals
707
+ { id: 'k2026a', key: process.env.TOTP_KEY_K2026A ?? '' }, // still opens
708
+ ],
709
+ },
710
+ ```
711
+
712
+ A key can go once no stored second-factor secret starts with `v1.<its id>.`.
713
+
714
+ ### `<call>: the secret does not open with the key "<id>" — was that key changed under the same id, or the secret copied from another user?`
715
+
716
+ A `TypeError`.
717
+
718
+ **When:** `activate` or `confirm`.
719
+ **Why:** the key held under that id is not the one that sealed the secret: its value was changed and its id kept, or two environments sharing one database hold different keys under one id. It is also the message for a sealed secret copied onto another user — a seal is bound to the user's id — for example a user record duplicated by hand.
720
+ **Fix:** restore the original key under that id. A new key always takes a new id. For a copied record, disable the factor and have the user enroll again: `await auth.secondFactor.disable(user)`.
721
+
722
+ ### `<call>: the stored secret is not a sealed one`
723
+
724
+ A `TypeError`.
725
+
726
+ **When:** `activate` or `confirm`.
727
+ **Why:** the stored secret is not `v1.<key id>.<iv>.<sealed>`: it was written into the store directly — a plain base32 secret imported from another system — or cut short.
728
+ **Fix:** never write the second factor into the store yourself. Disable it and have the user enroll again:
729
+
730
+ ```ts
731
+ await auth.secondFactor.disable(user);
732
+ ```
733
+
734
+ ### `<call>: the <type> type does not sign in with a password, so it has no second factor`
735
+
736
+ A `TypeError`.
737
+
738
+ **When:** `secondFactor.enroll`, from JavaScript, on a user type without `password`. In TypeScript, `secondFactor` is absent from such a type.
739
+ **Why:** a second factor is asked for after a password. A type that signs in otherwise has nothing to ask it after, and no login to show in the app.
740
+ **Fix:** enroll only users of a type with `password: { login }`.
741
+
742
+ ---
743
+
489
744
  ## Permissions
490
745
 
491
746
  ### `PERMISSION_DEPTH` — `can: checking <type>#<permission> crossed more than <n> relations without an answer`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxgt/janus",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Embeddable, type-safe identities and permissions: bring your own database",
5
5
  "license": "MIT",
6
6
  "type": "module",