@nxgt/janus 0.11.0 → 0.12.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 (79) hide show
  1. package/README.md +101 -15
  2. package/dist/auth/config/janus-config.d.ts +2 -0
  3. package/dist/auth/config/janus-config.d.ts.map +1 -1
  4. package/dist/auth/config/resolved-config.d.ts +1 -0
  5. package/dist/auth/config/resolved-config.d.ts.map +1 -1
  6. package/dist/auth/index.d.ts +2 -1
  7. package/dist/auth/index.d.ts.map +1 -1
  8. package/dist/auth/port/assert-stores.d.ts.map +1 -1
  9. package/dist/auth/port/memory/sessions.d.ts.map +1 -1
  10. package/dist/auth/port/types/sessions.d.ts +16 -1
  11. package/dist/auth/port/types/sessions.d.ts.map +1 -1
  12. package/dist/auth/port/types/tokens.d.ts +7 -2
  13. package/dist/auth/port/types/tokens.d.ts.map +1 -1
  14. package/dist/auth/second-factor/{regenerate-attempts.d.ts → app-code-attempts.d.ts} +13 -10
  15. package/dist/auth/second-factor/app-code-attempts.d.ts.map +1 -0
  16. package/dist/auth/sessions/authenticate.d.ts.map +1 -1
  17. package/dist/auth/sessions/fresh.d.ts +26 -0
  18. package/dist/auth/sessions/fresh.d.ts.map +1 -0
  19. package/dist/auth/sessions/standing.d.ts +20 -0
  20. package/dist/auth/sessions/standing.d.ts.map +1 -0
  21. package/dist/auth/step-up/codes.d.ts +29 -0
  22. package/dist/auth/step-up/codes.d.ts.map +1 -0
  23. package/dist/auth/step-up/flows.d.ts +19 -0
  24. package/dist/auth/step-up/flows.d.ts.map +1 -0
  25. package/dist/auth/step-up/open.d.ts +23 -0
  26. package/dist/auth/step-up/open.d.ts.map +1 -0
  27. package/dist/auth/step-up/request.d.ts +12 -0
  28. package/dist/auth/step-up/request.d.ts.map +1 -0
  29. package/dist/auth/types/index.d.ts +1 -0
  30. package/dist/auth/types/index.d.ts.map +1 -1
  31. package/dist/auth/types/janus.d.ts +2 -1
  32. package/dist/auth/types/janus.d.ts.map +1 -1
  33. package/dist/auth/types/step-up.d.ts +79 -0
  34. package/dist/auth/types/step-up.d.ts.map +1 -0
  35. package/dist/auth/users/any-type-api.d.ts +2 -2
  36. package/dist/auth/users/any-type-api.d.ts.map +1 -1
  37. package/dist/auth/users/type-api.d.ts +1 -1
  38. package/dist/auth/users/type-api.d.ts.map +1 -1
  39. package/dist/chunks/{index-5e2fw7he.js → index-j2gd8fry.js} +12 -3
  40. package/dist/chunks/{index-5e2fw7he.js.map → index-j2gd8fry.js.map} +4 -4
  41. package/dist/chunks/{index-5tm04t5p.js → index-jp2kka6d.js} +2 -2
  42. package/dist/chunks/{index-6f6wja93.js → index-km7ffctx.js} +2 -2
  43. package/dist/chunks/{index-4m49cye4.js → index-rd2rddvv.js} +15 -12
  44. package/dist/chunks/{index-4m49cye4.js.map → index-rd2rddvv.js.map} +3 -3
  45. package/dist/conformance/cases/outage.d.ts.map +1 -1
  46. package/dist/conformance/cases/sessions/index.d.ts.map +1 -1
  47. package/dist/conformance/cases/sessions/reauthentication.d.ts +4 -0
  48. package/dist/conformance/cases/sessions/reauthentication.d.ts.map +1 -0
  49. package/dist/conformance/cases/tokens/index.d.ts.map +1 -1
  50. package/dist/conformance/cases/tokens/kinds.d.ts +4 -0
  51. package/dist/conformance/cases/tokens/kinds.d.ts.map +1 -0
  52. package/dist/conformance/index.js +147 -60
  53. package/dist/conformance/index.js.map +8 -6
  54. package/dist/errors/janus-error/codes.d.ts +7 -0
  55. package/dist/errors/janus-error/codes.d.ts.map +1 -1
  56. package/dist/errors/janus-error/index.d.ts +1 -1
  57. package/dist/errors/janus-error/index.d.ts.map +1 -1
  58. package/dist/errors/janus-error/refusals.d.ts +8 -0
  59. package/dist/errors/janus-error/refusals.d.ts.map +1 -1
  60. package/dist/errors/janus-error/status.d.ts.map +1 -1
  61. package/dist/index.d.ts +1 -1
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +175 -18
  64. package/dist/index.js.map +16 -10
  65. package/dist/permissions/index.js +3 -3
  66. package/docs/README.md +1 -0
  67. package/docs/guide/adapters.md +38 -4
  68. package/docs/guide/errors.md +1 -0
  69. package/docs/guide/second-factor.md +22 -16
  70. package/docs/guide/sessions.md +7 -0
  71. package/docs/guide/step-up.md +178 -0
  72. package/docs/guide/users.md +1 -0
  73. package/docs/guide/vocabulary.md +3 -2
  74. package/docs/roadmap.md +12 -16
  75. package/docs/troubleshooting.md +182 -7
  76. package/package.json +1 -1
  77. package/dist/auth/second-factor/regenerate-attempts.d.ts.map +0 -1
  78. /package/dist/chunks/{index-5tm04t5p.js.map → index-jp2kka6d.js.map} +0 -0
  79. /package/dist/chunks/{index-6f6wja93.js.map → index-km7ffctx.js.map} +0 -0
@@ -88,6 +88,17 @@ How the messages are shaped:
88
88
  - [`TS2339: Property 'signInCode' does not exist on type 'TypeApi<…>'.`](#ts2339-property-signincode-does-not-exist-on-type-typeapi)
89
89
  - `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.
90
90
 
91
+ **Step-up**
92
+ - [`STEP_UP_REQUIRED` — `assertFresh: the session proved who it is longer ago than maxAge …`](#step_up_required--assertfresh-the-session-proved-who-it-is-longer-ago-than-maxage--confirm-with-a-step-up)
93
+ - [`TOKEN_UNKNOWN` — `stepUp.confirm: no such challenge`, with a challenge just issued](#token_unknown--stepupconfirm-no-such-challenge-with-a-challenge-just-issued)
94
+ - [`TOKEN_UNKNOWN` — `stepUp.confirm: the session was signed out while the code was checked`](#token_unknown--stepupconfirm-the-session-was-signed-out-while-the-code-was-checked)
95
+ - [`SECOND_FACTOR_ACTIVE` — `stepUp.confirm: the user's second factor became active since the code was sent …`](#second_factor_active--stepupconfirm-the-users-second-factor-became-active-since-the-code-was-sent--request-a-step-up-again)
96
+ - [`SECOND_FACTOR_NOT_ENROLLED` — `stepUp.confirm: the user no longer has a second factor …`](#second_factor_not_enrolled--stepupconfirm-the-user-no-longer-has-a-second-factor--request-a-step-up-again)
97
+ - [`CODE_INVALID` — `stepUp.confirm: too many codes tried …`](#code_invalid--stepupconfirm-too-many-codes-tried--wait-for-the-next-15-minute-window)
98
+ - [`NOT_FOUND` — `stepUp.request: the user has no e-mail`](#not_found--stepuprequest-the-user-has-no-e-mail)
99
+ - [`TS2339: Property 'code' does not exist on type 'StepUpByEmail<…> | StepUpByApp<…>'.`](#ts2339-property-code-does-not-exist-on-type-stepupbyemail--stepupbyapp)
100
+ - [`TS2339: Property 'stepUp' does not exist on type 'TypeApi<…>'.`](#ts2339-property-stepup-does-not-exist-on-type-typeapi)
101
+
91
102
  **User events**
92
103
  - [`[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)
93
104
  - [An event you expected never arrived](#an-event-you-expected-never-arrived)
@@ -116,6 +127,8 @@ How the messages are shaped:
116
127
  - [`expected <Class>, got <value>`](#expected-class-got-value)
117
128
  - [`expected null, got undefined — an absence is null; undefined is a store that forgot to answer`](#expected-null-got-undefined--an-absence-is-null-undefined-is-a-store-that-forgot-to-answer)
118
129
  - [`findUser should answer recoveryCodes [] as [] — never null, never undefined`](#finduser-should-answer-recoverycodes--as---never-null-never-undefined)
130
+ - [`reauthenticateSession racing revokeSession: the session should stay revoked — one conditional write, never a read then a write`](#reauthenticatesession-racing-revokesession-the-session-should-stay-revoked--one-conditional-write-never-a-read-then-a-write)
131
+ - [`tokens.insertToken: the store could not answer`, in `tokens.everyKind`](#tokensinserttoken-the-store-could-not-answer-in-tokenseverykind)
119
132
 
120
133
  ---
121
134
 
@@ -249,7 +262,8 @@ An adapter written for an earlier `@nxgt/janus` reports the method a later
249
262
  release added to the port: `store.tokens has no method countAttempt` for one
250
263
  written against 0.3 (the method came in 0.4), and
251
264
  `store.tokens has no method spendUserTokens` for one written against 0.6 (the
252
- method came in 0.7).
265
+ method came in 0.7), and `store.sessions has no method reauthenticateSession`
266
+ for one written against 0.11 (the method came in 0.12).
253
267
 
254
268
  **Fix:** pass the three stores, whole:
255
269
 
@@ -259,16 +273,17 @@ import { createMemoryStores, janus } from '@nxgt/janus';
259
273
  janus({ ..., store: createMemoryStores() });
260
274
  ```
261
275
 
262
- Upgrade the published adapter to the release that implements both —
263
- `@nxgt/janus-drizzle` 0.3, `@nxgt/janus-mongo` 0.4, `@nxgt/janus-redis` 0.3:
276
+ Upgrade the published adapter to the release that implements all three —
277
+ `@nxgt/janus-drizzle` 0.5, `@nxgt/janus-mongo` 0.6, `@nxgt/janus-redis` 0.4:
264
278
 
265
279
  ```bash
266
- bun add @nxgt/janus@^0.7 @nxgt/janus-drizzle@^0.3 # or @nxgt/janus-mongo@^0.4, @nxgt/janus-redis@^0.3
280
+ bun add @nxgt/janus@^0.12 @nxgt/janus-drizzle@^0.5 # or @nxgt/janus-mongo@^0.6, @nxgt/janus-redis@^0.4
267
281
  ```
268
282
 
269
283
  Your own adapter implements them as
270
- [`TokenStore.countAttempt`](guide/adapters.md#tokenstorecountattempt) and
271
- [`TokenStore.spendUserTokens`](guide/adapters.md#tokenstorespendusertokens)
284
+ [`TokenStore.countAttempt`](guide/adapters.md#tokenstorecountattempt),
285
+ [`TokenStore.spendUserTokens`](guide/adapters.md#tokenstorespendusertokens) and
286
+ [`SessionStore.reauthenticateSession`](guide/adapters.md#sessionstorereauthenticatesession)
272
287
  set out, then runs the conformance suite.
273
288
 
274
289
  ### `janus: relations must be a relation store — relations.deleteEntity is missing`
@@ -313,7 +328,7 @@ Also `janus: "<name>" cannot name a user type — janus() answers a method of th
313
328
 
314
329
  ### `janus: session.lifespan: "<value>" is not a duration; write a number followed by ms, s, m, h or d — for example "15m" or "720h"`
315
330
 
316
- 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`.
331
+ The same for `session.renewAfter`, `tokens.verifyEmail`, `tokens.resetPassword`, `tokens.signInCode`, `tokens.stepUp` and `secondFactor.challenge` — and, at call time rather than in `janus()`, `assertFresh: maxAge: "<value>" is not a duration; …`, from a `maxAge` written wrong. Also `<option>: a duration must be above zero` and `<option>: a duration in milliseconds must be a finite number above zero`.
317
332
 
318
333
  **When:** `janus({...})`.
319
334
  **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.
@@ -1073,6 +1088,140 @@ janus({
1073
1088
 
1074
1089
  ---
1075
1090
 
1091
+ ## Step-up
1092
+
1093
+ The messages start with `stepUp.request` or `stepUp.confirm` — prefixed by
1094
+ the type with several user types: `patient.stepUp.confirm: …`.
1095
+ `stepUp.confirm` also rejects with
1096
+ [`CODE_INVALID`](#code_invalid--call-the-code-does-not-match-or-was-already-used),
1097
+ [`TOKEN_UNKNOWN`, `TOKEN_SPENT`, `TOKEN_EXPIRED`](#token_unknown-token_spent-token_expired),
1098
+ [`TOKEN_STALE`](#token_stale--call-the-code-was-sent-to-an-e-mail-the-user-no-longer-has),
1099
+ [`USER_INACTIVE`](#user_inactive--call-the-user-is-inactive) and
1100
+ [`VERSION_CONFLICT`](#version_conflict--call-expected-version-n-found-m),
1101
+ as `signInCode.confirm` does. [The step-up guide](guide/step-up.md) has the
1102
+ whole flow.
1103
+
1104
+ ### `STEP_UP_REQUIRED` — `assertFresh: the session proved who it is longer ago than maxAge — confirm with a step-up`
1105
+
1106
+ `StepUpRequiredError`, carrying the `userId`; `statusOf` answers 403, and
1107
+ so does `@nxgt/janus-hono`'s `janusErrors()`.
1108
+
1109
+ **When:** `assertFresh(session, maxAge)` — or `fresh(maxAge)` in a Hono
1110
+ app, whose message starts `fresh():` instead — on a session that signed
1111
+ in, or was last confirmed by a step-up, `maxAge` ago or more.
1112
+ **Why:** the route asks for a recent proof of who the user is; renewal
1113
+ keeps a session alive but proves nothing, so it never moves
1114
+ `authenticatedAt`.
1115
+ **Fix:** this is not a denial. Answer 403 with the code, and have the
1116
+ client run a step-up, then send the request again:
1117
+
1118
+ ```ts
1119
+ const issued = await auth.stepUp.request(current.user);
1120
+ // … the user types the code …
1121
+ await auth.stepUp.confirm(request, challenge, code); // authenticatedAt: now
1122
+ ```
1123
+
1124
+ ### `TOKEN_UNKNOWN` — `stepUp.confirm: no such challenge`, with a challenge just issued
1125
+
1126
+ **When:** `stepUp.confirm(request, challenge, code)`, with the challenge
1127
+ `stepUp.request` answered a moment ago.
1128
+ **Why**, in the order to check:
1129
+
1130
+ 1. **The request presents no standing session.** `confirm` stamps the
1131
+ session the request carries: pass the request itself — the cookie or the
1132
+ `Authorization: Bearer` header — not a new one built without it.
1133
+ 2. **The session is another user's.** A challenge is confirmed on its
1134
+ user's own session only; one carried to another browser, signed in as
1135
+ someone else, is unknown there.
1136
+ 3. **The session was signed out, revoked or lapsed** since the challenge
1137
+ was issued — a password reset revokes every session.
1138
+ 4. **It is a sign-in code's challenge.** A step-up is a kind of its own: a
1139
+ `signInCode` challenge never confirms one.
1140
+
1141
+ **Fix:** confirm on the same request that asked; after a sign-out, sign in
1142
+ again — which is fresh on its own.
1143
+
1144
+ ### `TOKEN_UNKNOWN` — `stepUp.confirm: the session was signed out while the code was checked`
1145
+
1146
+ **When:** `stepUp.confirm`, when the session was revoked — a sign-out in
1147
+ another tab, a password reset — between the check of the code and the
1148
+ write that stamps the session.
1149
+ **Why:** a confirmation never brings a revoked session back: the store
1150
+ writes `authenticatedAt` only while `revokedAt` is empty. The challenge was
1151
+ spent.
1152
+ **Fix:** answer 401 and sign in again.
1153
+
1154
+ ### `SECOND_FACTOR_ACTIVE` — `stepUp.confirm: the user's second factor became active since the code was sent — request a step-up again`
1155
+
1156
+ `SecondFactorError`, carrying the `userId`.
1157
+
1158
+ **When:** `stepUp.confirm` with an e-mailed code, for a user who activated
1159
+ their second factor after `stepUp.request`.
1160
+ **Why:** a step-up is never weaker than the sign-in the account asks for —
1161
+ once the factor is active, only the app's code confirms. The challenge is
1162
+ spent.
1163
+ **Fix:** call `stepUp.request` again: it answers `via: 'secondFactor'`, and
1164
+ the user types the code from their app.
1165
+
1166
+ ### `SECOND_FACTOR_NOT_ENROLLED` — `stepUp.confirm: the user no longer has a second factor — request a step-up again`
1167
+
1168
+ `SecondFactorError`, carrying the `userId`.
1169
+
1170
+ **When:** `stepUp.confirm` on a challenge issued `via: 'secondFactor'`, for
1171
+ a user whose factor was disabled since.
1172
+ **Why:** there is no secret left to check the app's code against. The
1173
+ challenge is spent.
1174
+ **Fix:** call `stepUp.request` again: it answers `via: 'email'`, with a code
1175
+ to send.
1176
+
1177
+ ### `CODE_INVALID` — `stepUp.confirm: too many codes tried — wait for the next 15-minute window`
1178
+
1179
+ `TokenError`, `attemptsLeft: 0`.
1180
+
1181
+ **When:** `stepUp.confirm` with an app's code, after five app codes for
1182
+ the same user in the current 15-minute window — across step-ups and
1183
+ `secondFactor.regenerateRecoveryCodes`, which share the count.
1184
+ **Why:** a challenge takes five attempts, but a stolen session could ask
1185
+ for challenge after challenge; the window caps its guesses per user.
1186
+ **Fix:** answer with `attemptsLeft: 0`, and let the user try again in the
1187
+ next window. It is the
1188
+ [same count as `regenerateRecoveryCodes`](#code_invalid--secondfactorregeneraterecoverycodes-too-many-codes-tried--wait-for-the-next-15-minute-window).
1189
+
1190
+ ### `NOT_FOUND` — `stepUp.request: the user has no e-mail`
1191
+
1192
+ `NotFoundError`, carrying the `userId`.
1193
+
1194
+ **When:** `stepUp.request(user)` for a user whose e-mail field is empty —
1195
+ an optional field left unset — and whose second factor is not active.
1196
+ **Why:** the code has nowhere to be sent.
1197
+ **Fix:** ask for the e-mail first. A user with an active second factor
1198
+ never meets this: the step-up asks for their app's code.
1199
+
1200
+ ### `TS2339: Property 'code' does not exist on type 'StepUpByEmail<…> | StepUpByApp<…>'.`
1201
+
1202
+ Followed by `Property 'code' does not exist on type 'StepUpByApp<…>'.`
1203
+
1204
+ **When:** `tsc`, on `issued.code` after `stepUp.request`, with
1205
+ `secondFactor` configured on a type with a password.
1206
+ **Why:** a user whose factor is active is sent no code — the answer is
1207
+ `via: 'secondFactor'`, with a challenge only.
1208
+ **Fix:** narrow on `via` first:
1209
+
1210
+ ```ts
1211
+ const issued = await auth.stepUp.request(user);
1212
+ if (issued.via === 'email') await sendMail(issued.email, issued.code);
1213
+ ```
1214
+
1215
+ ### `TS2339: Property 'stepUp' does not exist on type 'TypeApi<…>'.`
1216
+
1217
+ **When:** `tsc`, on `clinic.<type>.stepUp` or `auth.stepUp`.
1218
+ **Why:** the user type has no e-mail, as for
1219
+ [`signInCode`](#ts2339-property-signincode-does-not-exist-on-type-typeapi):
1220
+ a code has nowhere to be sent.
1221
+ **Fix:** name the field that holds the e-mail with the `email` option.
1222
+
1223
+ ---
1224
+
1076
1225
  ## User events
1077
1226
 
1078
1227
  ### `[JANUS_EVENT_FAILED] Warning: janus: the events listener failed on <type> <event id> for user <user id>: <name>`
@@ -1364,3 +1513,29 @@ secondFactor:
1364
1513
  recoveryCodes: [...(document.secondFactor.recoveryCodes ?? [])],
1365
1514
  },
1366
1515
  ```
1516
+
1517
+ ### `reauthenticateSession racing revokeSession: the session should stay revoked — one conditional write, never a read then a write`
1518
+
1519
+ Also `reauthenticateSession on a revoked session — a confirmation racing a revocation must never bring it back`, followed by `expected null, got {…}`.
1520
+
1521
+ **When:** the cases `sessions.reauthenticateRace` and `sessions.reauthenticate`, on an adapter written before `@nxgt/janus` 0.12, or one that implemented `reauthenticateSession` as a read followed by a write.
1522
+ **Why:** a step-up moves `authenticatedAt` with `reauthenticateSession`. An adapter that reads the session, then writes it back whole, writes `revokedAt: null` over a revocation that landed between the two: a signed-out session stands again.
1523
+ **Fix:** one conditional write, whose condition holds `revokedAt` null — the one `extendSession` already has:
1524
+
1525
+ ```ts
1526
+ // MongoDB
1527
+ const written = await sessions.findOneAndUpdate(
1528
+ { _id: id, revokedAt: null },
1529
+ { $set: { authenticatedAt: at } },
1530
+ { returnDocument: 'after' },
1531
+ );
1532
+ return written === null ? null : toSession(written);
1533
+ ```
1534
+
1535
+ In SQL, `update … set authenticated_at = $2 where id = $1 and revoked_at is null returning *`; in Redis, a Lua script that checks `revokedAt == ''` before its `HSET`. See [`SessionStore.reauthenticateSession`](guide/adapters.md#sessionstorereauthenticatesession).
1536
+
1537
+ ### `tokens.insertToken: the store could not answer`, in `tokens.everyKind`
1538
+
1539
+ **When:** the case `tokens.everyKind`, on an adapter whose database lists the token kinds — a `CHECK`, a validator's enum.
1540
+ **Why:** the list does not hold `stepUp`, which came in `@nxgt/janus` 0.12, so the database refuses the insert, and the adapter reports it as the outage it cannot tell apart.
1541
+ **Fix:** add `stepUp` to the list, and ship the migration or the validator with it: `'verifyEmail', 'resetPassword', 'secondFactor', 'signInCode', 'stepUp'`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxgt/janus",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Embeddable, type-safe identities and permissions: bring your own database",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1 +0,0 @@
1
- {"version":3,"file":"regenerate-attempts.d.ts","sourceRoot":"","sources":["../../../src/auth/second-factor/regenerate-attempts.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,YAAY,CAAC;AAE1C,OAAO,KAAK,EAAe,UAAU,EAAE,MAAM,eAAe,CAAC;AAC7D,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,YAAY,CAAC;AAGzC;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,iEAAiE;AACjE,eAAO,MAAM,oBAAoB,QAAc,CAAC;AA0ChD;;;;GAIG;AACH,wBAAsB,sBAAsB,CAC3C,OAAO,EAAE,OAAO,EAChB,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,UAAU,EAClB,KAAK,EAAE,MAAM,GACX,OAAO,CAAC,MAAM,CAAC,CAgBjB"}