@nxgt/janus 0.4.0 → 0.6.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 (60) hide show
  1. package/README.md +159 -20
  2. package/dist/auth/config.d.ts +28 -1
  3. package/dist/auth/config.d.ts.map +1 -1
  4. package/dist/auth/context.d.ts +6 -0
  5. package/dist/auth/context.d.ts.map +1 -1
  6. package/dist/auth/index.d.ts +3 -2
  7. package/dist/auth/index.d.ts.map +1 -1
  8. package/dist/auth/one-time.d.ts +92 -0
  9. package/dist/auth/one-time.d.ts.map +1 -0
  10. package/dist/auth/port/types.d.ts +10 -7
  11. package/dist/auth/port/types.d.ts.map +1 -1
  12. package/dist/auth/sealing.d.ts +39 -0
  13. package/dist/auth/sealing.d.ts.map +1 -0
  14. package/dist/auth/second-factor/challenge.d.ts +14 -0
  15. package/dist/auth/second-factor/challenge.d.ts.map +1 -0
  16. package/dist/auth/second-factor/factor.d.ts +27 -0
  17. package/dist/auth/second-factor/factor.d.ts.map +1 -0
  18. package/dist/auth/second-factor/flows.d.ts +23 -0
  19. package/dist/auth/second-factor/flows.d.ts.map +1 -0
  20. package/dist/auth/second-factor/lifecycle.d.ts +8 -0
  21. package/dist/auth/second-factor/lifecycle.d.ts.map +1 -0
  22. package/dist/auth/sessions.d.ts.map +1 -1
  23. package/dist/auth/sign-in-code.d.ts +17 -0
  24. package/dist/auth/sign-in-code.d.ts.map +1 -0
  25. package/dist/auth/totp.d.ts +36 -0
  26. package/dist/auth/totp.d.ts.map +1 -0
  27. package/dist/auth/types.d.ts +140 -7
  28. package/dist/auth/types.d.ts.map +1 -1
  29. package/dist/auth/users.d.ts +2 -2
  30. package/dist/auth/users.d.ts.map +1 -1
  31. package/dist/chunks/{index-qwfkhqkk.js → index-06vp9c5r.js} +12 -3
  32. package/dist/chunks/{index-qwfkhqkk.js.map → index-06vp9c5r.js.map} +3 -3
  33. package/dist/chunks/{index-53y1afjz.js → index-5vr13kkb.js} +2 -2
  34. package/dist/chunks/{index-mgh85djb.js → index-c4v27jfr.js} +2 -2
  35. package/dist/chunks/{index-thtyq7a9.js → index-gbwn2tts.js} +2 -2
  36. package/dist/conformance/cases/tokens.d.ts.map +1 -1
  37. package/dist/conformance/index.js +15 -4
  38. package/dist/conformance/index.js.map +3 -3
  39. package/dist/errors/janus-error.d.ts +28 -2
  40. package/dist/errors/janus-error.d.ts.map +1 -1
  41. package/dist/index.d.ts +1 -1
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +478 -51
  44. package/dist/index.js.map +15 -7
  45. package/dist/permissions/index.js +3 -3
  46. package/docs/README.md +2 -0
  47. package/docs/guide/adapters.md +12 -7
  48. package/docs/guide/email-flows.md +4 -0
  49. package/docs/guide/errors.md +19 -7
  50. package/docs/guide/second-factor.md +571 -0
  51. package/docs/guide/sessions.md +18 -0
  52. package/docs/guide/sign-in-code.md +471 -0
  53. package/docs/guide/users.md +10 -3
  54. package/docs/guide/vocabulary.md +9 -6
  55. package/docs/roadmap.md +39 -54
  56. package/docs/troubleshooting.md +414 -5
  57. package/package.json +1 -1
  58. /package/dist/chunks/{index-53y1afjz.js.map → index-5vr13kkb.js.map} +0 -0
  59. /package/dist/chunks/{index-mgh85djb.js.map → index-c4v27jfr.js.map} +0 -0
  60. /package/dist/chunks/{index-thtyq7a9.js.map → index-gbwn2tts.js.map} +0 -0
package/docs/roadmap.md CHANGED
@@ -5,16 +5,20 @@ dates here, and the version something shipped in is the only number.
5
5
 
6
6
  ## Now
7
7
 
8
- - **One-time codes** — a one-time token short enough to type, sent by e-mail
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.
8
+ Nothing between releases.
15
9
 
16
10
  ## Next
17
11
 
12
+ - **Confirm an action with an e-mailed code (step-up)** — a signed-in user
13
+ proves they still read their inbox before something a stolen session should
14
+ not do alone: changing the e-mail, disabling the second factor, deleting
15
+ the account. The same six digits, challenge and five attempts as a sign-in
16
+ code, bound to the session that asked rather than opening one — which
17
+ takes a token kind of its own, so a sign-in code can never confirm an
18
+ action nor an action's code sign anyone in.
19
+ - **Recovery codes** — single-use codes for the TOTP second factor, so a
20
+ user who loses their authenticator app can still sign in, without an
21
+ operator resetting the account.
18
22
  - **Sending the e-mails** — in a package of its own, `@nxgt/janus-mail`, built
19
23
  on a general mail toolkit shared with applications that are not about
20
24
  sign-in: a `Mailer` port you plug your transport into (SMTP, Resend, SES…) —
@@ -44,8 +48,10 @@ dates here, and the version something shipped in is the only number.
44
48
  - **More official adapters** — the ports are cut where atomicity is not
45
49
  required, so users, sessions and permission tuples can each live in the
46
50
  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.
51
+ ([`@nxgt/janus-mongo`](https://www.npmjs.com/package/@nxgt/janus-mongo)),
52
+ PostgreSQL and Redis followed
53
+ ([`@nxgt/janus-drizzle`](https://www.npmjs.com/package/@nxgt/janus-drizzle),
54
+ [`@nxgt/janus-redis`](https://www.npmjs.com/package/@nxgt/janus-redis)).
49
55
 
50
56
  ## Not planned
51
57
 
@@ -84,14 +90,31 @@ dates here, and the version something shipped in is the only number.
84
90
 
85
91
  ## Shipped
86
92
 
87
- Each entry names the version it came in.
93
+ The last ten, newest first, each with the version it came in. Everything
94
+ before is in the [CHANGELOG](../CHANGELOG.md).
88
95
 
96
+ - **Sign in with a code sent by e-mail, v0.6.0** —
97
+ `auth.<type>.signInCode.request(email)` answers a six-digit code to send
98
+ and a challenge to keep with the visitor, or `null` for nobody — never
99
+ saying which; `signInCode.confirm(challenge, code)` marks the e-mail
100
+ verified and opens the session. On every user type with an e-mail, one
101
+ without a password included; an active second factor is still asked for.
102
+ A challenge lives ten minutes (`tokens.signInCode`) and takes five
103
+ attempts, and only the code's hash is stored, keyed by the challenge.
104
+ - **A TOTP second factor, v0.5.0** — `janus({ secondFactor: { issuer, keys } })`
105
+ and `auth.<type>.secondFactor`'s `enroll`, `activate`, `disable` and
106
+ `confirm`, for every user type with a password; each secret sealed with
107
+ AES-256-GCM under keys your application holds, and rotated by adding a key.
108
+ Breaking once configured: `signIn` answers `{ status: 'signedIn', … }` or
109
+ `{ status: 'secondFactor', challenge, expiresAt }`. A challenge lives five
110
+ minutes and takes five attempts, a code is accepted once, and a refused one
111
+ throws `CODE_INVALID` with `attemptsLeft`.
89
112
  - **The store port holds a second factor and counts attempts on a token,
90
113
  v0.4.0** — `UserRecord.secondFactor`, a token's `codeHash` and `attempts`,
91
114
  the token kinds `'secondFactor'` and `'signInCode'`, and
92
115
  `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
116
+ conformance cases. The published adapters implement them in
117
+ `@nxgt/janus-drizzle` 0.2, `@nxgt/janus-mongo` 0.3 and
95
118
  `@nxgt/janus-redis` 0.2.
96
119
  - **Permissions on a user, v0.3.0** — a user type may also be an object type:
97
120
  a staff member is the object `can()` asks about and is granted relations
@@ -109,7 +132,10 @@ Each entry names the version it came in.
109
132
  audit trail, never a login, a password, a session token or a one-time token;
110
133
  and [`@nxgt/janus-kit`](https://www.npmjs.com/package/@nxgt/janus-kit), all
111
134
  of it wired in one call.
112
-
135
+ - **A NUL character or a lone surrogate never reaches a store** — refused in
136
+ fields with `USER_INVALID` on every adapter, rather than `STORE_FAILED` on
137
+ PostgreSQL alone; a login holding one is nobody's. The conformance suite
138
+ holds every adapter to round-tripping every other character. — v0.2.1
113
139
  - **The model's keys read `related` and `permits`** — Keto's OPL words: an
114
140
  object type declares `related: { members: ['staff', 'team#members'] }` and
115
141
  `permits: { view: ['members'] }`, and relation names are plural by convention. Breaking:
@@ -123,48 +149,7 @@ Each entry names the version it came in.
123
149
  - **The conformance suite accepts a store with its own expiry** — a store
124
150
  that drops a lapsed session at once, as a Redis TTL does, passes
125
151
  `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
152
  - **A Hono integration** — [`@nxgt/janus-hono`](https://www.npmjs.com/package/@nxgt/janus-hono):
132
153
  the session middleware, the cookie, a route guarded by a permission,
133
154
  `bindJanus()` to bind the instances once, and every error as its status.
134
155
  Its own 0.1.0, beside `@nxgt/janus` 0.1.3.
135
- - **`defineModel` completed by your editor** — subject types and subject sets
136
- in a relation, subject types in `fromField`, relations, permissions and
137
- arrows in a rule and in `when`; a wrong name's error lists the names it
138
- 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,26 @@ 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
+
77
+ **Sign-in codes**
78
+ - [`TOKEN_STALE` — `<call>: the code was sent to an e-mail the user no longer has`](#token_stale--call-the-code-was-sent-to-an-e-mail-the-user-no-longer-has)
79
+ - [The code from an earlier e-mail is refused with `CODE_INVALID`](#the-code-from-an-earlier-e-mail-is-refused-with-code_invalid)
80
+ - [`signInCode.request` answers `null` for a user who exists](#signincoderequest-answers-null-for-a-user-who-exists)
81
+ - [`TS2339: Property 'signInCode' does not exist on type 'TypeApi<…>'.`](#ts2339-property-signincode-does-not-exist-on-type-typeapi)
82
+ - `CODE_INVALID`, `TOKEN_UNKNOWN`, `TOKEN_SPENT`, `TOKEN_EXPIRED`, `USER_INACTIVE` and `VERSION_CONFLICT` from `signInCode.confirm`, and `TS2339` on its `token`: in their entries above.
83
+
61
84
  **Permissions**
62
85
  - [`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
86
  - [`permissions: this model was not made by defineModel() …`](#permissions-this-model-was-not-made-by-definemodel--pass-what-definemodel-answered)
@@ -73,6 +96,7 @@ How the messages are shaped:
73
96
 
74
97
  **Subjects**
75
98
  - [`parseTuple: "<text>" is not a relation tuple; expected type:id#relation@subject`](#parsetuple-text-is-not-a-relation-tuple-expected-typeidrelationsubject)
99
+ - [`setOf: pass a user or { type, id }, then a relation`](#setof-pass-a-user-or--type-id--then-a-relation)
76
100
 
77
101
  **Conformance (adapter authors)**
78
102
  - [`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 +298,7 @@ Also `janus: "<name>" cannot name a user type — janus() answers a method of th
274
298
 
275
299
  ### `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
300
 
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`.
301
+ The same for `session.renewAfter`, `tokens.verifyEmail`, `tokens.resetPassword`, `tokens.signInCode` 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
302
 
279
303
  **When:** `janus({...})`.
280
304
  **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 +316,48 @@ Also `janus: cookie.name must be a cookie-name token — letters, digits and !#$
292
316
  **Why:** browsers drop a `SameSite=None` cookie that is not `Secure`, so every sign-in would silently fail to stick.
293
317
  **Fix:** keep `secure: true` with `sameSite: 'none'`, or use the default `sameSite: 'lax'` when the site and the API share a site.
294
318
 
319
+ ### `janus: secondFactor.issuer must name your application — the authenticator app shows it beside the account`
320
+
321
+ **When:** `janus({ ..., secondFactor })`, when `issuer` is missing, is not a string, or is blank.
322
+ **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.
323
+ **Fix:**
324
+
325
+ ```ts
326
+ janus({ ..., secondFactor: { issuer: 'Acme', keys } });
327
+ ```
328
+
329
+ ### `janus: secondFactor.keys: the key "<id>" is not 32 bytes in base64 — make one with openssl rand -base64 32`
330
+
331
+ Also:
332
+
333
+ - `janus: secondFactor.keys: expected at least one key — [{ id, key }], the first seals`
334
+ - `janus: secondFactor.keys: every key needs an id of letters, digits, _ and -, at most 64 of them`
335
+ - `janus: secondFactor.keys: two keys have the id "<id>"`
336
+
337
+ **When:** `janus({ ..., secondFactor })`.
338
+ **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.
339
+ **Fix:** make each key once and keep it with your other secrets:
340
+
341
+ ```sh
342
+ openssl rand -base64 32
343
+ ```
344
+
345
+ ```ts
346
+ janus({
347
+ ...,
348
+ secondFactor: {
349
+ issuer: 'Acme',
350
+ keys: [{ id: 'k2026a', key: process.env.TOTP_KEY_K2026A ?? '' }], // the first key seals
351
+ },
352
+ });
353
+ ```
354
+
355
+ ### `"hasSecondFactor" is a field janus sets itself; rename it`
356
+
357
+ **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.
358
+ **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`.
359
+ **Fix:** rename the field in your schema, and read `user.hasSecondFactor` for janus's answer.
360
+
295
361
  ### Other `janus:` wiring messages
296
362
 
297
363
  | Message | Fix |
@@ -365,6 +431,23 @@ const user = await auth.get(id);
365
431
  await auth.update(user, { name }, { ifVersion: user.version });
366
432
  ```
367
433
 
434
+ **From `secondFactor.confirm`**, the message is the store's:
435
+ `updateUser: expected version <n>, found <m>`.
436
+
437
+ **When:** two `confirm` calls for one user run at once with the same code — a
438
+ double-submitted form, two tabs, a client that retries before the first answer.
439
+ **Why:** of two codes accepted at once, the first write wins and opens a
440
+ session. The second finds the user's version moved and writes nothing: no
441
+ session. Its challenge is not spent, and has lost one attempt.
442
+ **Fix:** submit the code form once. The session already exists, from the call
443
+ that won; if the visitor still needs one, the next code works on the same
444
+ challenge — the same code will not, it was used.
445
+
446
+ ```ts
447
+ // in the browser: one submit per code
448
+ form.addEventListener('submit', () => form.querySelector('button')?.setAttribute('disabled', ''));
449
+ ```
450
+
368
451
  ### `USER_INVALID` — `<call>: the fields do not match the <type> schema (<n> issues, at <paths>)`
369
452
 
370
453
  `UserInvalidError`, carrying `issues` — each a `path` and a `message`.
@@ -416,9 +499,9 @@ janus({ ..., hasher: scryptHasher(), verifiers: [bcryptVerifier] }); // a Passwo
416
499
 
417
500
  ### `USER_INACTIVE` — `<call>: the user is inactive`
418
501
 
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)`.
502
+ **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, and `signInCode.confirm`, for a user set inactive after the code was sent.
503
+ **Why:** an inactive user keeps their record and password, and every sign-in is refused. It is checked after the password, so only somebody who knows the password learns the user is inactive — and after the code, so only somebody who read the e-mail does: `signInCode.request` answers `null` for an inactive user, as for nobody. On either `confirm`, the challenge is spent: reactivating the user does not revive it.
504
+ **Fix:** answer 403, or reactivate, then sign in again: `await auth.setActive(user, true)`.
422
505
 
423
506
  ### `TOKEN_UNKNOWN`, `TOKEN_SPENT`, `TOKEN_EXPIRED`
424
507
 
@@ -432,6 +515,46 @@ janus({ ..., hasher: scryptHasher(), verifiers: [bcryptVerifier] }); // a Passwo
432
515
  janus({ ..., tokens: { verifyEmail: '72h', resetPassword: '2h' } });
433
516
  ```
434
517
 
518
+ **On `secondFactor.confirm`**, the messages name the challenge `signIn`
519
+ answered: `secondFactor.confirm: no such challenge`,
520
+ `secondFactor.confirm: the challenge was already used`,
521
+ `secondFactor.confirm: the challenge has expired`.
522
+
523
+ **When:** `secondFactor.confirm(challenge, code)`.
524
+ **Why:** a challenge lives five minutes and takes five codes. It is spent by
525
+ the code that opens the session, by the fifth wrong code, and by a refusal
526
+ that ends it (`USER_INACTIVE`, `SECOND_FACTOR_NOT_ENROLLED`). `TOKEN_UNKNOWN`
527
+ also covers a challenge whose user was deleted, and a challenge passed where a
528
+ code was expected — the two arguments swapped.
529
+ **Fix:** answer 400 and send the visitor back to sign in, which asks for a new
530
+ code. To give slower visitors more time:
531
+
532
+ ```ts
533
+ janus({ ..., secondFactor: { issuer: 'Acme', keys, challenge: '10m' } });
534
+ ```
535
+
536
+ **On `signInCode.confirm`**, the messages name the challenge
537
+ `signInCode.request` answered: `signInCode.confirm: no such challenge`,
538
+ `signInCode.confirm: the challenge was already used`,
539
+ `signInCode.confirm: the challenge has expired`.
540
+
541
+ **When:** `signInCode.confirm(challenge, code)`.
542
+ **Why:** a challenge lives ten minutes and takes five codes. It is spent by
543
+ the code that signs the user in, by the fifth wrong code, and by a refusal
544
+ that ends it (`TOKEN_STALE`, `USER_INACTIVE`). `TOKEN_UNKNOWN` also covers a
545
+ challenge whose user was deleted, one confirmed through another user type's
546
+ `signInCode`, the decoy challenge of a `request` that answered `null`, and
547
+ the two arguments swapped. Another type's `confirm` compares no code and
548
+ spends nothing, but it has already cost one of the challenge's five
549
+ attempts: the attempt is counted before the type is known. The challenge is
550
+ left for its own type, with one attempt fewer.
551
+ **Fix:** answer 400 and offer to send a new code. To give slower inboxes
552
+ more time:
553
+
554
+ ```ts
555
+ janus({ ..., tokens: { signInCode: '15m' } });
556
+ ```
557
+
435
558
  ### `TOKEN_STALE` — `<call>: the token was sent to an e-mail the user no longer has`
436
559
 
437
560
  **When:** `verifyEmail.confirm` or `resetPassword.confirm`, after the user changed their e-mail.
@@ -486,6 +609,292 @@ const page = await access.list(user, 'view', 'record', {
486
609
 
487
610
  ---
488
611
 
612
+ ## Second factor
613
+
614
+ Every entry here needs `janus({ ..., secondFactor })`, but for the
615
+ `signInCode.confirm` paragraph of `CODE_INVALID`. The messages start with
616
+ `secondFactor.enroll`, `secondFactor.activate`, `secondFactor.confirm` or
617
+ `signIn` — prefixed by the type with several user types:
618
+ `staff.secondFactor.confirm: …`. `secondFactor.confirm` also rejects with
619
+ [`TOKEN_UNKNOWN`, `TOKEN_SPENT`, `TOKEN_EXPIRED`](#token_unknown-token_spent-token_expired),
620
+ [`USER_INACTIVE`](#user_inactive--call-the-user-is-inactive) and
621
+ [`VERSION_CONFLICT`](#version_conflict--call-expected-version-n-found-m);
622
+ each of those entries has a paragraph for it.
623
+
624
+ ### `TS2339: Property 'token' does not exist on type 'SignInResult<…>'.`
625
+
626
+ The same for `session` and `user`.
627
+
628
+ **When:** `tsc`, wherever `signIn`'s answer is read, once `janus()` is given a `secondFactor` — and `signInCode.confirm`'s, on a user type with a password.
629
+ **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.
630
+ **Fix:** switch on `status`:
631
+
632
+ ```ts
633
+ const result = await auth.signIn({ email, password });
634
+ if (result.status === 'secondFactor') {
635
+ // Keep the challenge for the next request — never in a URL — and ask for a code.
636
+ return Response.json({ challenge: result.challenge, expiresAt: result.expiresAt });
637
+ }
638
+ return Response.json({ token: result.token });
639
+
640
+ // the next request, with the code the app shows
641
+ const signedIn = await auth.secondFactor.confirm(challenge, code); // { status: 'signedIn', token, … }
642
+ ```
643
+
644
+ ### `CODE_INVALID` — `<call>: the code does not match, or was already used`
645
+
646
+ `TokenError`. The same message answers three calls; there is a paragraph
647
+ for each below.
648
+
649
+ **When:** `secondFactor.activate`, `secondFactor.confirm` or
650
+ `signInCode.confirm`, with a code that does not match.
651
+
652
+ **`secondFactor.activate(user, code)`**
653
+ **Why:** the code is wrong, or it was accepted before: a code is accepted
654
+ once. There is no challenge here: no `attemptsLeft`, and nothing is spent.
655
+ **Fix:** let the user try the next code the app shows. If it is the one on
656
+ screen, see [the next entry](#the-code-the-authenticator-app-shows-is-refused-with-code_invalid).
657
+
658
+ **`secondFactor.confirm(challenge, code)`**
659
+ **Why:** the code is wrong, or it was accepted before. Every call costs one of
660
+ the challenge's five attempts, counted before anything is checked. The error
661
+ carries `attemptsLeft`, what remains; at `0`, the challenge is spent and the
662
+ next `confirm` is `TOKEN_SPENT`.
663
+ **Fix:** answer 401 with `attemptsLeft`, and send the visitor back to sign in
664
+ once it is `0`:
665
+
666
+ ```ts
667
+ import { TokenError } from '@nxgt/janus';
668
+
669
+ try {
670
+ return await auth.secondFactor.confirm(challenge, code);
671
+ } catch (error) {
672
+ if (error instanceof TokenError && error.code === 'CODE_INVALID') {
673
+ return Response.json({ code: error.code, attemptsLeft: error.attemptsLeft }, { status: 401 });
674
+ }
675
+ throw error;
676
+ }
677
+ ```
678
+
679
+ **`signInCode.confirm(challenge, code)`** — the e-mailed code.
680
+ **Why:** the code is not the one sent with this challenge, or is not six
681
+ digits — a space, a dash, a code pasted with its label. The error carries
682
+ `attemptsLeft` and the `userId` of the user the code was sent to. Every call
683
+ costs one of the challenge's five attempts, counted before the code is
684
+ compared — a call through another user type's `signInCode` too, although it
685
+ answers `TOKEN_UNKNOWN`. At `0` the challenge is spent, and the next
686
+ `confirm` is `TOKEN_SPENT`, even with the right code. Codes sent at once past
687
+ the fifth attempt are all refused, the right one included.
688
+ **Fix:** answer 401 with `attemptsLeft`, and request a new code once it is
689
+ `0`. Strip what the visitor may have typed around the digits before calling
690
+ `confirm`:
691
+
692
+ ```ts
693
+ import { TokenError } from '@nxgt/janus';
694
+
695
+ try {
696
+ return await auth.signInCode.confirm(challenge, code.replace(/\D/g, ''));
697
+ } catch (error) {
698
+ if (error instanceof TokenError && error.code === 'CODE_INVALID') {
699
+ return Response.json({ code: error.code, attemptsLeft: error.attemptsLeft }, { status: 401 });
700
+ }
701
+ throw error;
702
+ }
703
+ ```
704
+
705
+ If the visitor typed the code from the e-mail correctly, see
706
+ [the code from an earlier e-mail](#the-code-from-an-earlier-e-mail-is-refused-with-code_invalid).
707
+
708
+ ### The code the authenticator app shows is refused with `CODE_INVALID`
709
+
710
+ **When:** `activate` or `confirm`, with the code on screen, typed correctly.
711
+ **Why**, in the order to check:
712
+
713
+ 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.
714
+ 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.
715
+ 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.
716
+
717
+ **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`.
718
+
719
+ ```sh
720
+ timedatectl show -p NTPSynchronized # NTPSynchronized=yes on the server
721
+ ```
722
+
723
+ ### `SECOND_FACTOR_NOT_ENROLLED` — `secondFactor.activate: the user has no second factor waiting — call enroll first`
724
+
725
+ `SecondFactorError`. Also `secondFactor.confirm: the user no longer has a second factor — sign in again`.
726
+
727
+ **When:** `activate` before `enroll`, or after `disable`. On `confirm`: the factor was disabled after `signIn` asked for a code.
728
+ **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.
729
+ **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.
730
+
731
+ ```ts
732
+ const { secret, uri } = await auth.secondFactor.enroll(user);
733
+ // …the user scans uri, or types secret…
734
+ await auth.secondFactor.activate(user, code);
735
+ ```
736
+
737
+ ### `SECOND_FACTOR_ACTIVE` — `secondFactor.enroll: the user's second factor is active — disable it first`
738
+
739
+ `SecondFactorError`. Also `secondFactor.activate: the user's second factor is already active`.
740
+
741
+ **When:** `enroll` or `activate` for a user whose factor is already active — often a form submitted twice, or a "set up again" button.
742
+ **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.
743
+ **Fix:** check `user.hasSecondFactor` first. To move to a new phone, disable, then enroll:
744
+
745
+ ```ts
746
+ const cleared = await auth.secondFactor.disable(user);
747
+ const { uri } = await auth.secondFactor.enroll(cleared);
748
+ ```
749
+
750
+ ### `<call>: …, and janus() was given no secondFactor — pass secondFactor: { issuer, keys }`
751
+
752
+ Most often: `signIn: the user's second factor is active, and janus() was given no secondFactor — pass secondFactor: { issuer, keys }`. A `TypeError`.
753
+
754
+ **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.
755
+ **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.
756
+ **Fix:** give every `janus()` over the same users the same `secondFactor`, from one module:
757
+
758
+ ```ts
759
+ // auth-config.ts, imported by every process
760
+ export const secondFactor = { issuer: 'Acme', keys } as const;
761
+
762
+ janus({ ..., secondFactor });
763
+ ```
764
+
765
+ ### `<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`
766
+
767
+ A `TypeError`.
768
+
769
+ **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.
770
+ **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.
771
+ **Fix:** put the old key back, after the new one:
772
+
773
+ ```ts
774
+ secondFactor: {
775
+ issuer: 'Acme',
776
+ keys: [
777
+ { id: 'k2026b', key: process.env.TOTP_KEY_K2026B ?? '' }, // seals
778
+ { id: 'k2026a', key: process.env.TOTP_KEY_K2026A ?? '' }, // still opens
779
+ ],
780
+ },
781
+ ```
782
+
783
+ A key can go once no stored second-factor secret starts with `v1.<its id>.`.
784
+
785
+ ### `<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?`
786
+
787
+ A `TypeError`.
788
+
789
+ **When:** `activate` or `confirm`.
790
+ **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.
791
+ **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)`.
792
+
793
+ ### `<call>: the stored secret is not a sealed one`
794
+
795
+ A `TypeError`.
796
+
797
+ **When:** `activate` or `confirm`.
798
+ **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.
799
+ **Fix:** never write the second factor into the store yourself. Disable it and have the user enroll again:
800
+
801
+ ```ts
802
+ await auth.secondFactor.disable(user);
803
+ ```
804
+
805
+ ### `<call>: the <type> type does not sign in with a password, so it has no second factor`
806
+
807
+ A `TypeError`.
808
+
809
+ **When:** `secondFactor.enroll`, from JavaScript, on a user type without `password`. In TypeScript, `secondFactor` is absent from such a type.
810
+ **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.
811
+ **Fix:** enroll only users of a type with `password: { login }`.
812
+
813
+ ---
814
+
815
+ ## Sign-in codes
816
+
817
+ The messages start with `signInCode.confirm` — prefixed by the type with
818
+ several user types: `patient.signInCode.confirm: …`. `signInCode.request`
819
+ throws nothing but `STORE_FAILED`: an e-mail it cannot sign in is `null`.
820
+ `signInCode.confirm` also rejects with
821
+ [`CODE_INVALID`](#code_invalid--call-the-code-does-not-match-or-was-already-used),
822
+ [`TOKEN_UNKNOWN`, `TOKEN_SPENT`, `TOKEN_EXPIRED`](#token_unknown-token_spent-token_expired),
823
+ [`USER_INACTIVE`](#user_inactive--call-the-user-is-inactive) and
824
+ [`VERSION_CONFLICT`](#version_conflict--call-expected-version-n-found-m);
825
+ each of those entries has a paragraph for it.
826
+ [The sign-in code guide](guide/sign-in-code.md) has the whole flow.
827
+
828
+ ### `TOKEN_STALE` — `<call>: the code was sent to an e-mail the user no longer has`
829
+
830
+ `TokenError`, carrying the `userId`.
831
+
832
+ **When:** `signInCode.confirm`, after the user's e-mail was changed — by
833
+ `update`, or a patch naming the e-mail field — since the code was sent.
834
+ **Why:** the code proves an address, and signing in with it would mark as
835
+ verified an address the user no longer holds. The challenge is spent.
836
+ **Fix:** answer 400, and request a new code: it goes to the current address.
837
+
838
+ ```ts
839
+ const issued = await auth.signInCode.request(currentEmail);
840
+ ```
841
+
842
+ ### The code from an earlier e-mail is refused with `CODE_INVALID`
843
+
844
+ **When:** the visitor asked for a code twice — pressed "send again", or
845
+ opened the form in two tabs — and typed the code of the first e-mail.
846
+ **Why:** every `request` issues a new code **with its own challenge**, and a
847
+ code is checked against the challenge it was sent with. The cookie or the
848
+ form field now holds the second challenge, so the first code does not
849
+ match it — and costs an attempt.
850
+ **Fix:** tell the visitor that only the last code sent works, and put the
851
+ time it was sent in the e-mail's subject or text so they can tell the
852
+ e-mails apart. The earlier challenge is not revoked: it still expires on its
853
+ own.
854
+
855
+ ### `signInCode.request` answers `null` for a user who exists
856
+
857
+ **When:** `signInCode.request(email)`, for an address you can see in the
858
+ database.
859
+ **Why**, in the order to check:
860
+
861
+ 1. **The user is inactive.** An inactive user gets no code, and the answer
862
+ is the same as for nobody.
863
+ 2. **It is another user type.** `clinic.patient.signInCode.request` looks
864
+ among patients only; the same address may hold a user of another type.
865
+ 3. **The address is not the type's e-mail field.** A type whose `email`
866
+ option names `contact` is looked up by `contact`. A login that looks like
867
+ an e-mail — a `username` of `ada@example.com` — is not an e-mail, and is
868
+ `null` too.
869
+
870
+ The e-mail is trimmed and lowercased before the lookup, so case and
871
+ surrounding spaces are never the cause.
872
+ **Fix:** `setActive(user, true)`; call the right type's `signInCode`; name
873
+ the field with `email: 'contact'`. Do not tell the visitor which case it
874
+ was — the route answers the same page either way.
875
+
876
+ ### `TS2339: Property 'signInCode' does not exist on type 'TypeApi<…>'.`
877
+
878
+ Also `Property 'signInCode' does not exist on type 'Janus<…>'.`, with the
879
+ single-type form, `janus({ user })`.
880
+
881
+ **When:** `tsc`, on `auth.signInCode` or `clinic.<type>.signInCode`.
882
+ **Why:** the user type has no e-mail: no field called `email`, and no
883
+ `email` option naming one. A code has nowhere to be sent, so the flow is
884
+ absent from the type.
885
+ **Fix:** name the field that holds the e-mail:
886
+
887
+ ```ts
888
+ janus({
889
+ users: {
890
+ staff: { schema: Staff, password: { login: 'username' }, email: 'workEmail' },
891
+ },
892
+ ...
893
+ });
894
+ ```
895
+
896
+ ---
897
+
489
898
  ## Permissions
490
899
 
491
900
  ### `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.6.0",
4
4
  "description": "Embeddable, type-safe identities and permissions: bring your own database",
5
5
  "license": "MIT",
6
6
  "type": "module",