@nxgt/janus 0.3.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 (58) hide show
  1. package/README.md +135 -15
  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 +4 -3
  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 +74 -3
  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-8tksqzkr.js → index-gbwn2tts.js} +14 -3
  33. package/dist/chunks/{index-8tksqzkr.js.map → index-gbwn2tts.js.map} +3 -3
  34. package/dist/conformance/cases/outage.d.ts.map +1 -1
  35. package/dist/conformance/cases/tokens.d.ts.map +1 -1
  36. package/dist/conformance/cases/users.d.ts.map +1 -1
  37. package/dist/conformance/fixtures.d.ts.map +1 -1
  38. package/dist/conformance/index.js +122 -5
  39. package/dist/conformance/index.js.map +6 -6
  40. package/dist/errors/janus-error.d.ts +28 -2
  41. package/dist/errors/janus-error.d.ts.map +1 -1
  42. package/dist/index.d.ts +1 -1
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +390 -40
  45. package/dist/index.js.map +15 -8
  46. package/dist/permissions/index.js +3 -3
  47. package/docs/README.md +2 -1
  48. package/docs/guide/adapters.md +129 -3
  49. package/docs/guide/errors.md +18 -6
  50. package/docs/guide/second-factor.md +569 -0
  51. package/docs/guide/sessions.md +18 -0
  52. package/docs/guide/users.md +8 -2
  53. package/docs/guide/vocabulary.md +6 -1
  54. package/docs/roadmap.md +33 -46
  55. package/docs/troubleshooting.md +276 -5
  56. package/package.json +1 -1
  57. /package/dist/chunks/{index-53y1afjz.js.map → index-5vr13kkb.js.map} +0 -0
  58. /package/dist/chunks/{index-mgh85djb.js.map → index-c4v27jfr.js.map} +0 -0
@@ -31,7 +31,7 @@ describeJanusStores({
31
31
 
32
32
  | Port | Taken by | Methods |
33
33
  | --- | --- | --- |
34
- | `JanusStores` — `{ users: UserStore, sessions: SessionStore, tokens: TokenStore }` | `janus({ store })` | 6 + 6 (+ 1 optional) + 3 |
34
+ | `JanusStores` — `{ users: UserStore, sessions: SessionStore, tokens: TokenStore }` | `janus({ store })` | 6 + 6 (+ 1 optional) + 4 |
35
35
  | `RelationStore` | `permissions({ store })`, `janus({ relations })` | 6 |
36
36
 
37
37
  They are separate on purpose: an application that only authenticates
@@ -77,6 +77,45 @@ interface UserStore {
77
77
  - `listUsers` pages in ascending id order; `after` is the last id of the
78
78
  previous page, already checked by the core.
79
79
 
80
+ #### A user's password and second factor
81
+
82
+ Both are one field of `UserRecord`, `null` when the user has none, and a
83
+ patch treats both alike: **absent keeps it, `null` removes it, a value
84
+ replaces it whole**.
85
+
86
+ ```ts
87
+ interface UserRecord {
88
+ // …id, type, schemaVersion, active, fields, logins…
89
+ readonly password: { readonly hash: string; readonly updatedAt: Date } | null;
90
+ readonly secondFactor: {
91
+ readonly method: 'totp';
92
+ readonly secret: string; // opaque: store it byte for byte
93
+ readonly confirmedAt: Date | null; // null while enrolment waits for a first code
94
+ readonly lastStep: number | null; // the time step of the last code accepted
95
+ } | null;
96
+ // …emailVerifiedAt, version, createdAt, updatedAt
97
+ }
98
+ ```
99
+
100
+ `secret` is **opaque to a store**: the core seals it with a key the
101
+ application holds — AES-256-GCM, written `v1.<key id>.<iv>.<ciphertext>` —
102
+ before a store sees it, so a dump of the users cannot produce a code. Keep it
103
+ like a password hash — byte for byte, no parsing, no trimming. The core
104
+ rewrites it, sealed under another key, when the application
105
+ [rotates its keys](second-factor.md#rotating-the-keys). Store
106
+ the second factor whole: a method without a secret, or a `lastStep` without a
107
+ method, is a record the core never writes.
108
+
109
+ ```ts
110
+ import type { UserPatch } from '@nxgt/janus';
111
+
112
+ const keep: UserPatch = { updatedAt: new Date() }; // secondFactor untouched
113
+ const remove: UserPatch = { updatedAt: new Date(), secondFactor: null };
114
+ ```
115
+
116
+ An adapter that stored users before this field existed reads its absence as
117
+ `null`, never `undefined` (rule 2): a user with no second factor holds `null`.
118
+
80
119
  ### `SessionStore` and `TokenStore`
81
120
 
82
121
  ```ts
@@ -93,6 +132,7 @@ interface SessionStore {
93
132
  interface TokenStore {
94
133
  insertToken(record: TokenRecord): Promise<void>;
95
134
  consumeToken(tokenHash: string, kind: TokenKind, at: Date): Promise<TokenRecord | null>;
135
+ countAttempt(tokenHash: string, kind: TokenKind): Promise<TokenRecord | null>;
96
136
  deleteUserTokens(userId: Id): Promise<number>;
97
137
  }
98
138
  ```
@@ -103,12 +143,85 @@ Twenty concurrent calls must produce exactly one answer with `spentAt: null`;
103
143
  in MongoDB that is one `findOneAndUpdate` returning the document before the
104
144
  update. A read followed by a write lets two requests redeem one reset token.
105
145
 
146
+ A token is its hash, never its secret, and what it is for:
147
+
148
+ ```ts
149
+ type TokenKind = 'verifyEmail' | 'resetPassword' | 'secondFactor' | 'signInCode';
150
+
151
+ interface TokenRecord {
152
+ readonly tokenHash: string;
153
+ readonly kind: TokenKind;
154
+ readonly userId: Id;
155
+ readonly address: string; // '' for a secondFactor challenge: nothing was sent
156
+ readonly codeHash: string | null; // a signInCode's code, hashed; null for every other kind
157
+ readonly attempts: number; // 0 at insertion
158
+ readonly expiresAt: Date;
159
+ readonly spentAt: Date | null;
160
+ readonly createdAt: Date;
161
+ }
162
+ ```
163
+
164
+ A token redeemed for another kind is unknown: every method that takes a
165
+ `kind` matches on it. A `secondFactor` token is the challenge `signIn`
166
+ answers, and its `address` is `''`: a column or a validator that refuses an
167
+ empty string refuses every sign-in with a code. `codeHash` and `attempts` round-trip like every other
168
+ field. An adapter whose stored tokens predate them reads them as `null` and
169
+ `0`, as the three published adapters do, so no data migration is needed for
170
+ them.
171
+
106
172
  Expiry is the core's decision: a read answers a stored session verbatim,
107
173
  lapsed or revoked, and never a record it has changed. A store with its own
108
174
  expiry — a TTL index, a Redis key TTL — may drop a lapsed session or token
109
175
  before anyone asks: reads then answer `null`, and `deleteUserSessions` does
110
176
  not count it. The conformance suite accepts both.
111
177
 
178
+ ### `TokenStore.countAttempt`
179
+
180
+ Counts one attempt at a code against a token, and answers the token **as it
181
+ is after the call** — what bounds the attempts at a six-digit code:
182
+
183
+ | The stored token | Written | Answered |
184
+ | --- | --- | --- |
185
+ | unspent, of this `kind` | `attempts + 1` | the token, with the new count |
186
+ | spent, of this `kind` | nothing | the token as it is |
187
+ | another `kind`, or no token with this hash | nothing | `null` |
188
+
189
+ Like `consumeToken`, it is **one conditional write**, never a read followed by
190
+ a write: twenty concurrent calls answer the counts 1 to 20, each once. A count
191
+ two attempts both read is an attempt for free. Whether the count is past the
192
+ limit, and whether the code matches, is the core's decision after the call;
193
+ spending the token stays `consumeToken`'s.
194
+
195
+ A **lapsed** token is counted all the same, or answered `null` by a store that
196
+ has already dropped it (a TTL index, a Redis key TTL). Do not compare
197
+ `expiresAt` in the store: as for `consumeToken`, the core compares it after
198
+ the call.
199
+
200
+ In MongoDB, one `findOneAndUpdate` answering the document after it, then a
201
+ plain read for the spent case:
202
+
203
+ ```ts
204
+ import type { TokenStore } from '@nxgt/janus';
205
+
206
+ // tokens: your collection; toToken: your document → TokenRecord
207
+ export const countAttempt: TokenStore['countAttempt'] = async (tokenHash, kind) => {
208
+ const after = await tokens.findOneAndUpdate(
209
+ { _id: tokenHash, kind, spentAt: null },
210
+ { $inc: { attempts: 1 } },
211
+ { returnDocument: 'after' },
212
+ );
213
+ if (after !== null) return toToken(after);
214
+ const spent = await tokens.findOne({ _id: tokenHash, kind }); // written nothing
215
+ return spent === null ? null : toToken(spent);
216
+ };
217
+ ```
218
+
219
+ In SQL, `update … set attempts = attempts + 1 where token_hash = $1 and kind =
220
+ $2 and spent_at is null returning *`, then the same plain read. In Redis, one
221
+ Lua script: `HINCRBY` only when `spentAt` is empty, then `HGETALL`. Wrap the
222
+ driver's error in `StoreFailure` as in [the six rules](#the-six-rules):
223
+ `countAttempt` has its own outage case.
224
+
112
225
  ### `RelationStore`
113
226
 
114
227
  ```ts
@@ -172,7 +285,7 @@ An adapter **defines no error class**. It throws `@nxgt/janus`'s own
172
285
  copy of each class and `instanceof` holds in the application. A cursor it
173
286
  cannot read is `invalidCursor(where, cursor)`. Records, patches and page
174
287
  requests are exported as types: `UserRecord`, `UserPatch`, `UserPageRequest`,
175
- `PasswordRecord`, `SessionRecord`, `TokenRecord`, `TokenKind`, `Json`,
288
+ `PasswordRecord`, `SecondFactorRecord`, `SessionRecord`, `TokenRecord`, `TokenKind`, `Json`,
176
289
  `JsonObject`, and `ObjectPageRequest`, `RelationChanges` from
177
290
  `@nxgt/janus/permissions`.
178
291
 
@@ -185,7 +298,7 @@ compile error naming the missing method.
185
298
 
186
299
  | Suite | Cases | Harness opens |
187
300
  | --- | --- | --- |
188
- | `describeJanusStores({ name, harness, runner?, faults?, skip? })` | 38: users, sessions, tokens, and one outage per method whose honest answer can be "nothing" | `{ stores, faults?, close? }` |
301
+ | `describeJanusStores({ name, harness, runner?, faults?, skip? })` | 45: users, sessions, tokens, and one outage per method whose honest answer can be "nothing" — twelve of them | `{ stores, faults?, close? }` |
189
302
  | `describeRelationStores({ name, harness, runner?, faults?, skip? })` | 15: the relation store, and one outage per method | `{ store, faults?, close? }` |
190
303
 
191
304
  `harness.open()` is called **once per case** and must answer fresh, empty
@@ -202,6 +315,19 @@ stores: a case that leaks into the next is the hardest failure to debug.
202
315
 
203
316
  The suites import no test framework and no assertion library.
204
317
 
318
+ The second factor and attempts have their own cases — skip one by its id
319
+ while you work on it, never to ship:
320
+
321
+ | Case | Checks |
322
+ | --- | --- |
323
+ | `users.secondFactorSlot` | round-trip; a patch not naming it keeps it; `null` removes it |
324
+ | `tokens.countAttempt` | two calls answer `attempts` 1 then 2, `codeHash` as written; `consumeToken` answers the count |
325
+ | `tokens.countAttemptConcurrency` | twenty concurrent calls answer 1 to 20, each once |
326
+ | `tokens.countAttemptRace` | attempts racing one redemption: the counts answered unspent are 1 to the final count, and every answer after the spend carries that final count |
327
+ | `tokens.challenge` | a second-factor challenge, whose `address` is `''`, kept, counted and spent like any token |
328
+ | `tokens.countAttemptSpent` | a spent token answered unchanged; another kind and an unknown hash answer `null` and count nothing |
329
+ | `outage.countAttempt` | a store that cannot answer rejects, never `null` |
330
+
205
331
  ### `faults`: prove the outage invariant
206
332
 
207
333
  `faults` is optional, and **its absence is reported, never passed over**:
@@ -27,7 +27,11 @@ export function statusOf(code: JanusErrorCode): number {
27
27
  case 'TOKEN_STALE':
28
28
  return 400;
29
29
  case 'CREDENTIALS_INVALID':
30
+ case 'CODE_INVALID':
30
31
  return 401;
32
+ case 'SECOND_FACTOR_NOT_ENROLLED':
33
+ case 'SECOND_FACTOR_ACTIVE':
34
+ return 409;
31
35
  case 'USER_INACTIVE':
32
36
  return 403;
33
37
  case 'UNSUPPORTED':
@@ -39,11 +43,12 @@ export function statusOf(code: JanusErrorCode): number {
39
43
 
40
44
  export function toResponse(error: unknown): Response {
41
45
  if (!(error instanceof JanusError)) throw error;
42
- return Response.json({ code: error.code }, { status: statusOf(error.code) });
46
+ const body = error.code === 'CODE_INVALID' ? { code: error.code, attemptsLeft: error.attemptsLeft } : { code: error.code };
47
+ return Response.json(body, { status: statusOf(error.code) });
43
48
  }
44
49
  ```
45
50
 
46
- `JanusErrorCode` is a union of sixteen string literals, so that `switch` is
51
+ `JanusErrorCode` is a union of nineteen string literals, so that `switch` is
47
52
  exhaustive: when a code is added, a function like `statusOf` stops compiling
48
53
  instead of answering `undefined`.
49
54
 
@@ -61,7 +66,7 @@ told they do not exist.
61
66
  | Thrown | When | Class |
62
67
  | --- | --- | --- |
63
68
  | At **call** time, on a value that could have come from a request | a taken login, a wrong password, a spent token, an outage | a `JanusError` subclass, with a `code` |
64
- | At **wiring** time, from how you called the library | a lifespan that is not a duration, a store missing a method, a model with a loop, a malformed tuple string | a bare `TypeError` |
69
+ | At **wiring** time, from how you called the library | a lifespan that is not a duration, a store missing a method, a model with a loop, a malformed tuple string, a sealing key removed while secrets sealed with it are stored, a user with an active second factor signing in through a `janus()` given no `secondFactor` | a bare `TypeError` |
65
70
 
66
71
  No request handler should ever answer a `TypeError` — it is a bug in the code
67
72
  that wired the library, so no handler needs to tell it apart.
@@ -79,7 +84,10 @@ that wired the library, so no handler needs to tell it apart.
79
84
  | `CREDENTIALS_INVALID` | `CredentialError` | 401 | Unknown login, no password, or the wrong one — **one code for the three** | `reason`, for your logs only |
80
85
  | `HASH_UNSUPPORTED` | `CredentialError` | 400 | A stored hash no wired hasher reads | `hashPrefix` — never the hash |
81
86
  | `USER_INACTIVE` | `UserInactiveError` | 403 | Deactivated; told only to someone who gave the right password | `userId` |
82
- | `TOKEN_UNKNOWN`, `TOKEN_SPENT`, `TOKEN_EXPIRED`, `TOKEN_STALE` | `TokenError` | 400 | See [e-mail flows](email-flows.md#what-a-token-refusal-means) | |
87
+ | `TOKEN_UNKNOWN`, `TOKEN_SPENT`, `TOKEN_EXPIRED`, `TOKEN_STALE` | `TokenError` | 400 | See [e-mail flows](email-flows.md#what-a-token-refusal-means). For a second factor's challenge: sign in again | |
88
+ | `CODE_INVALID` | `TokenError` | 401 | A second factor's code that does not match, or was already accepted — see [the second factor](second-factor.md#confirming-the-code-at-sign-in) | `attemptsLeft` from `confirm`: what the challenge has left, `0` once it is spent. None from `activate` |
89
+ | `SECOND_FACTOR_NOT_ENROLLED` | `SecondFactorError` | 409 | `activate` before `enroll`, or `confirm` after the factor was disabled | `userId` |
90
+ | `SECOND_FACTOR_ACTIVE` | `SecondFactorError` | 409 | `enroll` or `activate` on a factor already active: `disable` it first | `userId` |
83
91
  | `INVALID_CURSOR` | `InvalidCursorError` | 400 | A cursor this store did not mint. Never a silent first page | |
84
92
  | `UNSUPPORTED` | `UnsupportedError` | 501 | The wired store lacks an optional capability — `collectExpired` without `deleteExpiredSessions` | `slot`, `operation` |
85
93
  | `PERMISSION_DEPTH` | `PermissionDepthError` | 500 | A check or list walked past `maxDepth`. **Not a denial** | `permission`, `maxDepth` |
@@ -114,13 +122,17 @@ async function signIn(email: string, password: string): Promise<Response> {
114
122
  would read as "no".
115
123
  - **`VERSION_CONFLICT` is a retry**: read the user again, reapply, write with
116
124
  the new `version`.
125
+ - **`CODE_INVALID`'s `attemptsLeft`** belongs in the body — the form can say
126
+ how many attempts are left. `0` means the challenge is spent: send the visitor
127
+ back to the password.
117
128
  - **`USER_INVALID`'s `issues`** have the schema's own paths
118
129
  (`['address', 'city']`), so a form can show each next to its field.
119
130
 
120
131
  ## No message holds a secret
121
132
 
122
- Not a password, not a hash, not a session token, not a token's hash, and not a
123
- connection URI — a connection string holds a password. Nor a login: a message
133
+ Not a password, not a hash, not a session token, not a token's hash, not a
134
+ challenge, a second factor's code or its secret, and not a connection URI — a
135
+ connection string holds a password. Nor a login: a message
124
136
  reports a shape, never a value, so `LOGIN_TAKEN` carries the login in
125
137
  `error.login` and not in its message. A message names the
126
138
  call you wrote (`signIn`, `users.findUser`) so you know where to look.