@nxgt/janus 0.5.0 → 0.7.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.
- package/README.md +116 -23
- package/dist/auth/config.d.ts +3 -0
- package/dist/auth/config.d.ts.map +1 -1
- package/dist/auth/context.d.ts +12 -0
- package/dist/auth/context.d.ts.map +1 -1
- package/dist/auth/email-flows.d.ts +11 -0
- package/dist/auth/email-flows.d.ts.map +1 -0
- package/dist/auth/index.d.ts +1 -1
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/one-time.d.ts +66 -6
- package/dist/auth/one-time.d.ts.map +1 -1
- package/dist/auth/password-written.d.ts +24 -0
- package/dist/auth/password-written.d.ts.map +1 -0
- package/dist/auth/port/assert-stores.d.ts.map +1 -1
- package/dist/auth/port/types.d.ts +23 -1
- package/dist/auth/port/types.d.ts.map +1 -1
- package/dist/auth/second-factor/challenge.d.ts +0 -6
- package/dist/auth/second-factor/challenge.d.ts.map +1 -1
- package/dist/auth/second-factor/factor.d.ts +1 -4
- package/dist/auth/second-factor/factor.d.ts.map +1 -1
- package/dist/auth/second-factor/flows.d.ts +7 -3
- package/dist/auth/second-factor/flows.d.ts.map +1 -1
- package/dist/auth/second-factor/lifecycle.d.ts.map +1 -1
- package/dist/auth/sign-in-code.d.ts +20 -0
- package/dist/auth/sign-in-code.d.ts.map +1 -0
- package/dist/auth/types.d.ts +56 -1
- package/dist/auth/types.d.ts.map +1 -1
- package/dist/auth/users.d.ts +2 -2
- package/dist/auth/users.d.ts.map +1 -1
- package/dist/chunks/{index-gbwn2tts.js → index-c0jajeda.js} +12 -2
- package/dist/chunks/{index-gbwn2tts.js.map → index-c0jajeda.js.map} +3 -3
- package/dist/conformance/cases/outage.d.ts.map +1 -1
- package/dist/conformance/cases/tokens.d.ts.map +1 -1
- package/dist/conformance/index.js +73 -2
- package/dist/conformance/index.js.map +4 -4
- package/dist/index.js +253 -116
- package/dist/index.js.map +15 -12
- package/docs/README.md +2 -1
- package/docs/guide/adapters.md +56 -3
- package/docs/guide/email-flows.md +11 -1
- package/docs/guide/errors.md +3 -3
- package/docs/guide/second-factor.md +31 -4
- package/docs/guide/sign-in-code.md +489 -0
- package/docs/guide/users.md +5 -4
- package/docs/guide/vocabulary.md +7 -6
- package/docs/roadmap.md +28 -13
- package/docs/troubleshooting.md +193 -28
- package/package.json +1 -1
package/docs/README.md
CHANGED
|
@@ -14,6 +14,7 @@ below are defined once, in [Words](guide/vocabulary.md#words).
|
|
|
14
14
|
| [Users](guide/users.md) | You are wiring `janus()`, declaring one or several user types, or calling `create`, `update`, `list`, `delete` |
|
|
15
15
|
| [Sessions](guide/sessions.md) | You need to know who a request belongs to, set or clear the cookie, renew, sign out, or test expiry |
|
|
16
16
|
| [E-mail verification and password reset](guide/email-flows.md) | You are sending a verification or reset link, and handling what comes back |
|
|
17
|
+
| [Signing in with an e-mailed code](guide/sign-in-code.md) | You are signing users in with a six-digit code sent by e-mail — with no password, or beside one: requesting it without telling who exists, keeping the challenge, the attempts and the errors |
|
|
17
18
|
| [The second factor](guide/second-factor.md) | You are turning on TOTP codes: making and rotating the sealing keys, the QR code, `signIn`'s `status`, confirming a challenge, disabling |
|
|
18
19
|
| [Password hashing](guide/passwords.md) | You are choosing a hasher, raising its cost, moving to argon2id, or importing hashes from another system |
|
|
19
20
|
|
|
@@ -29,6 +30,6 @@ below are defined once, in [Words](guide/vocabulary.md#words).
|
|
|
29
30
|
| --- | --- |
|
|
30
31
|
| [The shared vocabulary](guide/vocabulary.md) | You want the words the documentation uses, or subjects and the tuple notation, ids, cursors, durations or `fixedClock` |
|
|
31
32
|
| [Errors](guide/errors.md) | You are turning what the package throws into a status code, and want every code and what it carries |
|
|
32
|
-
| [Writing an adapter](guide/adapters.md) | You are implementing the identity stores or the relation store for your database, upgrading one for `countAttempt` and the second factor, or running the conformance suites |
|
|
33
|
+
| [Writing an adapter](guide/adapters.md) | You are implementing the identity stores or the relation store for your database, upgrading one for `countAttempt`, `spendUserTokens` and the second factor, or running the conformance suites |
|
|
33
34
|
| [Troubleshooting](troubleshooting.md) | You have an error message and want its cause and its fix |
|
|
34
35
|
| [Roadmap](roadmap.md) | You want to know what is coming, what shipped, and what is deliberately not planned |
|
package/docs/guide/adapters.md
CHANGED
|
@@ -133,6 +133,7 @@ interface TokenStore {
|
|
|
133
133
|
insertToken(record: TokenRecord): Promise<void>;
|
|
134
134
|
consumeToken(tokenHash: string, kind: TokenKind, at: Date): Promise<TokenRecord | null>;
|
|
135
135
|
countAttempt(tokenHash: string, kind: TokenKind): Promise<TokenRecord | null>;
|
|
136
|
+
spendUserTokens(userId: Id, kind: TokenKind, at: Date, except?: string): Promise<number>; // the unspent ones, but except
|
|
136
137
|
deleteUserTokens(userId: Id): Promise<number>;
|
|
137
138
|
}
|
|
138
139
|
```
|
|
@@ -222,6 +223,50 @@ Lua script: `HINCRBY` only when `spentAt` is empty, then `HGETALL`. Wrap the
|
|
|
222
223
|
driver's error in `StoreFailure` as in [the six rules](#the-six-rules):
|
|
223
224
|
`countAttempt` has its own outage case.
|
|
224
225
|
|
|
226
|
+
### `TokenStore.spendUserTokens`
|
|
227
|
+
|
|
228
|
+
Spends every **unspent** token of one user and one `kind` at `at` — but the
|
|
229
|
+
one whose hash is `except`, when given — and answers how many it spent. The
|
|
230
|
+
core calls it right after issuing a sign-in code, with that code's hash as
|
|
231
|
+
`except`, so only the last code sent works; and after writing a password,
|
|
232
|
+
for the user's `secondFactor` challenges:
|
|
233
|
+
|
|
234
|
+
| The stored token | Written | Counted |
|
|
235
|
+
| --- | --- | --- |
|
|
236
|
+
| unspent, of this user and this `kind` | `spentAt: at` | yes |
|
|
237
|
+
| already spent | nothing — `spentAt` never changes once set | no |
|
|
238
|
+
| another `kind`, another user, or the one named by `except` | nothing | no |
|
|
239
|
+
| none at all | nothing | `0`, an absence — never a failure |
|
|
240
|
+
|
|
241
|
+
Each token is spent by a **conditional write**, as `consumeToken` spends one:
|
|
242
|
+
a token that a racing `consumeToken` spends at the same moment is counted by
|
|
243
|
+
exactly one of the two calls, never both. An expired token is spent all the
|
|
244
|
+
same, or not counted by a store that already dropped it.
|
|
245
|
+
|
|
246
|
+
In MongoDB, one `updateMany` through the `userId` index `deleteUserTokens`
|
|
247
|
+
already reads:
|
|
248
|
+
|
|
249
|
+
```ts
|
|
250
|
+
import type { TokenStore } from '@nxgt/janus';
|
|
251
|
+
|
|
252
|
+
export const spendUserTokens: TokenStore['spendUserTokens'] = async (userId, kind, at, except) => {
|
|
253
|
+
const result = await tokens.updateMany(
|
|
254
|
+
{ userId, kind, spentAt: null, ...(except === undefined ? {} : { _id: { $ne: except } }) },
|
|
255
|
+
{ $set: { spentAt: at } },
|
|
256
|
+
);
|
|
257
|
+
return result.modifiedCount;
|
|
258
|
+
};
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
In SQL, `update … set spent_at = $3 where user_id = $1 and kind = $2 and
|
|
262
|
+
spent_at is null returning token_hash`, with `and token_hash <> $4` added
|
|
263
|
+
only when `except` is given — bound to `NULL`, `<>` matches no row — answering the row count: PostgreSQL
|
|
264
|
+
re-checks `spent_at is null` on a row a racing redemption just committed. In
|
|
265
|
+
Redis, one Lua script over the user's set of tokens, `HSET spentAt` on each
|
|
266
|
+
of the right `kind` whose `spentAt` is empty, skipping the one named by
|
|
267
|
+
`except`. `spendUserTokens` has its own
|
|
268
|
+
outage case.
|
|
269
|
+
|
|
225
270
|
### `RelationStore`
|
|
226
271
|
|
|
227
272
|
```ts
|
|
@@ -257,7 +302,11 @@ Written on the port's types, and checked by the suites:
|
|
|
257
302
|
normalises logins before a store sees them, and never hands a store
|
|
258
303
|
`\u0000` or a lone surrogate; every other character comes back as written.
|
|
259
304
|
5. **Every method is atomic on its own.** The core opens no transaction; an
|
|
260
|
-
adapter may open one inside a method.
|
|
305
|
+
adapter may open one inside a method. And **a read sees every write that
|
|
306
|
+
completed before it** — never a secondary or a read replica: a sign-in
|
|
307
|
+
re-reads the user to see a password written while it ran, and a new
|
|
308
|
+
sign-in code spends the ones issued before it. No suite can check this
|
|
309
|
+
one; a `readPreference: 'secondaryPreferred'` breaks it silently.
|
|
261
310
|
6. **Schema management is not on the port.** Expose your own `sync`; the core
|
|
262
311
|
never calls it.
|
|
263
312
|
|
|
@@ -298,7 +347,7 @@ compile error naming the missing method.
|
|
|
298
347
|
|
|
299
348
|
| Suite | Cases | Harness opens |
|
|
300
349
|
| --- | --- | --- |
|
|
301
|
-
| `describeJanusStores({ name, harness, runner?, faults?, skip? })` |
|
|
350
|
+
| `describeJanusStores({ name, harness, runner?, faults?, skip? })` | 49: users, sessions, tokens, and one outage per method whose honest answer can be "nothing" — thirteen of them | `{ stores, faults?, close? }` |
|
|
302
351
|
| `describeRelationStores({ name, harness, runner?, faults?, skip? })` | 15: the relation store, and one outage per method | `{ store, faults?, close? }` |
|
|
303
352
|
|
|
304
353
|
`harness.open()` is called **once per case** and must answer fresh, empty
|
|
@@ -315,7 +364,7 @@ stores: a case that leaks into the next is the hardest failure to debug.
|
|
|
315
364
|
|
|
316
365
|
The suites import no test framework and no assertion library.
|
|
317
366
|
|
|
318
|
-
The second factor and
|
|
367
|
+
The second factor, attempts and a user's tokens spent have their own cases — skip one by its id
|
|
319
368
|
while you work on it, never to ship:
|
|
320
369
|
|
|
321
370
|
| Case | Checks |
|
|
@@ -327,6 +376,10 @@ while you work on it, never to ship:
|
|
|
327
376
|
| `tokens.challenge` | a second-factor challenge, whose `address` is `''`, kept, counted and spent like any token |
|
|
328
377
|
| `tokens.countAttemptSpent` | a spent token answered unchanged; another kind and an unknown hash answer `null` and count nothing |
|
|
329
378
|
| `outage.countAttempt` | a store that cannot answer rejects, never `null` |
|
|
379
|
+
| `tokens.spendUserTokens` | spends the unspent tokens of one user and kind at `at`, keeping their attempts, and counts them; a spent token keeps its `spentAt`; another kind and another user are untouched; `0` for none |
|
|
380
|
+
| `tokens.spendUserTokensExcept` | spares the token named by `except`, and spends the user's others of that kind |
|
|
381
|
+
| `tokens.spendUserTokensRace` | racing one `consumeToken`, ten times over: exactly one of the two spends the token |
|
|
382
|
+
| `outage.spendUserTokens` | a store that cannot answer rejects, never `0` |
|
|
330
383
|
|
|
331
384
|
### `faults`: prove the outage invariant
|
|
332
385
|
|
|
@@ -51,6 +51,9 @@ clinic.patient.verifyEmail.send; // exists: `email` names the field
|
|
|
51
51
|
clinic.staff.verifyEmail;
|
|
52
52
|
```
|
|
53
53
|
|
|
54
|
+
A type with an e-mail can also sign in with a code sent to it, with or
|
|
55
|
+
without a password — see [sign-in codes](sign-in-code.md).
|
|
56
|
+
|
|
54
57
|
## Options
|
|
55
58
|
|
|
56
59
|
| Option | Type | Default | Effect |
|
|
@@ -71,6 +74,9 @@ readonly verifyEmail: {
|
|
|
71
74
|
`send` issues a token for the user's **current** e-mail. `confirm` redeems it
|
|
72
75
|
and sets `emailVerified`. A token sent to an e-mail the user has since changed
|
|
73
76
|
is `TOKEN_STALE`: confirming it would verify an address nobody holds any more.
|
|
77
|
+
The address is checked again on the very record the write replaces, so an
|
|
78
|
+
e-mail changed while the link is being redeemed is `TOKEN_STALE` too, and
|
|
79
|
+
nothing is written.
|
|
74
80
|
Changing the e-mail with `update` sets `emailVerified` back to `false`.
|
|
75
81
|
|
|
76
82
|
## `resetPassword`
|
|
@@ -97,7 +103,10 @@ export async function forgotPassword(request: Request): Promise<Response> {
|
|
|
97
103
|
```
|
|
98
104
|
|
|
99
105
|
`confirm` sets the password, marks the e-mail verified — the link proved it —
|
|
100
|
-
and **signs the user out everywhere
|
|
106
|
+
and **signs the user out everywhere**: their sessions are revoked, and every
|
|
107
|
+
second-factor challenge still open is spent, so a sign-in started with the
|
|
108
|
+
old password cannot be finished. The e-mail is checked again on the record
|
|
109
|
+
written, as for `verifyEmail`. It opens no session: call `signIn` next
|
|
101
110
|
if that is your policy. A password refused for its length does not spend the
|
|
102
111
|
token, so the visitor can try again with the same link.
|
|
103
112
|
|
|
@@ -142,5 +151,6 @@ never the token, and no refusal's message contains it.
|
|
|
142
151
|
## See also
|
|
143
152
|
|
|
144
153
|
- [Users](users.md) — `email`, `update`, and the other per-type methods
|
|
154
|
+
- [Sign-in codes](sign-in-code.md) — the third flow that sends an e-mail: a code, not a link
|
|
145
155
|
- [Sessions](sessions.md) — `signOutEverywhere`, which `resetPassword.confirm` calls for you
|
|
146
156
|
- [Errors](errors.md) — every code, and the status it deserves
|
package/docs/guide/errors.md
CHANGED
|
@@ -83,9 +83,9 @@ that wired the library, so no handler needs to tell it apart.
|
|
|
83
83
|
| `PASSWORD_TOO_SHORT` | `CredentialError` | 400 | Below `password.minLength` | `minLength` — never the password |
|
|
84
84
|
| `CREDENTIALS_INVALID` | `CredentialError` | 401 | Unknown login, no password, or the wrong one — **one code for the three** | `reason`, for your logs only |
|
|
85
85
|
| `HASH_UNSUPPORTED` | `CredentialError` | 400 | A stored hash no wired hasher reads | `hashPrefix` — never the hash |
|
|
86
|
-
| `USER_INACTIVE` | `UserInactiveError` | 403 | Deactivated; told only to someone who gave the right password | `userId` |
|
|
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
|
|
86
|
+
| `USER_INACTIVE` | `UserInactiveError` | 403 | Deactivated; told only to someone who gave the right password, or the right code | `userId` |
|
|
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; for a sign-in code's: request a new code — see [sign-in codes](sign-in-code.md#what-confirm-refuses) | `userId` on `TOKEN_STALE` |
|
|
88
|
+
| `CODE_INVALID` | `TokenError` | 401 | A one-time code that does not match: a second factor's, or one already accepted — see [the second factor](second-factor.md#confirming-the-code-at-sign-in) — or a sign-in code sent by e-mail — see [sign-in codes](sign-in-code.md#attempts) | `attemptsLeft` from either `confirm`: what the challenge has left, `0` once it is spent. None from `activate`. `userId` |
|
|
89
89
|
| `SECOND_FACTOR_NOT_ENROLLED` | `SecondFactorError` | 409 | `activate` before `enroll`, or `confirm` after the factor was disabled | `userId` |
|
|
90
90
|
| `SECOND_FACTOR_ACTIVE` | `SecondFactorError` | 409 | `enroll` or `activate` on a factor already active: `disable` it first | `userId` |
|
|
91
91
|
| `INVALID_CURSOR` | `InvalidCursorError` | 400 | A cursor this store did not mint. Never a silent first page | |
|
|
@@ -203,11 +203,13 @@ interface SecondFactorRequired {
|
|
|
203
203
|
readonly status: 'secondFactor';
|
|
204
204
|
readonly challenge: string; // a secret, like a session token
|
|
205
205
|
readonly expiresAt: Date; // five minutes from now, by default
|
|
206
|
+
readonly userId: Id; // for your logs and rate limits — not for the visitor
|
|
206
207
|
}
|
|
207
208
|
```
|
|
208
209
|
|
|
209
|
-
A user whose factor is active gets **no session from their password
|
|
210
|
-
|
|
210
|
+
A user whose factor is active gets **no session from their password** — nor
|
|
211
|
+
from a [code sent by e-mail](sign-in-code.md#a-second-factor-is-still-asked-for),
|
|
212
|
+
whose `confirm` answers the same union: they get a **challenge**, which `secondFactor.confirm` redeems with a code. Anyone
|
|
211
213
|
else gets a session, as before. `signIn`'s refusals — `CREDENTIALS_INVALID`,
|
|
212
214
|
`USER_INACTIVE` — are unchanged, and come before any challenge: a wrong
|
|
213
215
|
password never tells anyone that a second factor exists.
|
|
@@ -260,8 +262,8 @@ const signedIn = await auth.secondFactor.confirm(challenge, code);
|
|
|
260
262
|
| Rejects with | When | What to do |
|
|
261
263
|
| --- | --- | --- |
|
|
262
264
|
| `CODE_INVALID`, with `attemptsLeft` | the code does not match, is not six digits, or was already accepted | ask again while `attemptsLeft > 0`; at `0` the challenge is spent: sign in again |
|
|
263
|
-
| `TOKEN_UNKNOWN` | no such challenge — a typo, another user type's, or one a store's TTL already dropped | sign in again |
|
|
264
|
-
| `TOKEN_SPENT` | the challenge already opened a session,
|
|
265
|
+
| `TOKEN_UNKNOWN` | no such challenge — a typo, another user type's, or one a store's TTL already dropped. Another type's challenge still loses one of its attempts, and its fifth spends it | sign in again |
|
|
266
|
+
| `TOKEN_SPENT` | the challenge already opened a session, its attempts ran out, or a password reset spent it | sign in again |
|
|
265
267
|
| `TOKEN_EXPIRED` | `expiresAt` has passed | sign in again |
|
|
266
268
|
| `USER_INACTIVE` | the user was deactivated since `signIn`. The challenge is spent | answer 403, as `signIn` would |
|
|
267
269
|
| `SECOND_FACTOR_NOT_ENROLLED` | the factor was disabled since `signIn`. The challenge is spent | sign in again: the password alone now opens a session |
|
|
@@ -291,6 +293,30 @@ Five attempts at a million values is a one-in-200,000 chance per password
|
|
|
291
293
|
guessed right. A new challenge takes a new sign-in, with the password, so the
|
|
292
294
|
attempts are bounded by your sign-in rate limit too.
|
|
293
295
|
|
|
296
|
+
A call made through **another user type's** API — `auth.staff.secondFactor.confirm`
|
|
297
|
+
for a patient's challenge — answers `TOKEN_UNKNOWN` and compares nothing, but
|
|
298
|
+
its attempt counts all the same: the fifth spends the challenge, as a wrong
|
|
299
|
+
code would.
|
|
300
|
+
|
|
301
|
+
**Writing a password ends the sign-ins left waiting.** `resetPassword.confirm`,
|
|
302
|
+
`setPassword` and `changePassword` spend every challenge of the user still
|
|
303
|
+
open, so whoever had the old password cannot finish a sign-in they started
|
|
304
|
+
with it:
|
|
305
|
+
|
|
306
|
+
```ts
|
|
307
|
+
const result = await auth.signIn({ email, password: oldPassword }); // a challenge
|
|
308
|
+
await auth.resetPassword.confirm(resetToken, newPassword);
|
|
309
|
+
await auth.secondFactor.confirm(result.challenge, code); // TOKEN_SPENT
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
A sign-in still running when the password is written is refused too.
|
|
313
|
+
`signIn` reads the user again once it answered: if the password it verified
|
|
314
|
+
is no longer theirs, it spends its own challenge — or revokes the session it
|
|
315
|
+
opened — and throws `CREDENTIALS_INVALID`. The writer spends after writing,
|
|
316
|
+
the sign-in reads after issuing, so however the two interleave one of them
|
|
317
|
+
sees the other. A hash rewritten for the same password — another sign-in
|
|
318
|
+
rehashing it — is not a change.
|
|
319
|
+
|
|
294
320
|
### Lifetime
|
|
295
321
|
|
|
296
322
|
A challenge lives `'5m'` unless `secondFactor.challenge` says otherwise, and
|
|
@@ -562,6 +588,7 @@ with `STORE_FAILED`.
|
|
|
562
588
|
|
|
563
589
|
## See also
|
|
564
590
|
|
|
591
|
+
- [Sign-in codes](sign-in-code.md) — a sign-in by e-mailed code, which still asks for an active factor, with the same challenge
|
|
565
592
|
- [Sessions](sessions.md) — the cookie `confirm`'s session is sent in, and `authenticatedAt`
|
|
566
593
|
- [Errors](errors.md) — `CODE_INVALID`, `SECOND_FACTOR_NOT_ENROLLED`, `SECOND_FACTOR_ACTIVE` and their statuses
|
|
567
594
|
- [Writing an adapter](adapters.md#a-users-password-and-second-factor) — what a store keeps of a factor
|