@nxgt/janus 0.6.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 +52 -14
- package/dist/auth/context.d.ts +6 -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/one-time.d.ts +7 -0
- 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.map +1 -1
- package/dist/auth/sign-in-code.d.ts +6 -3
- package/dist/auth/sign-in-code.d.ts.map +1 -1
- package/dist/auth/types.d.ts +6 -0
- package/dist/auth/types.d.ts.map +1 -1
- 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 +145 -88
- package/dist/index.js.map +11 -9
- package/docs/README.md +1 -1
- package/docs/guide/adapters.md +56 -3
- package/docs/guide/email-flows.md +7 -1
- package/docs/guide/second-factor.md +27 -2
- package/docs/guide/sign-in-code.md +26 -8
- package/docs/guide/users.md +3 -3
- package/docs/guide/vocabulary.md +1 -0
- package/docs/roadmap.md +12 -4
- package/docs/troubleshooting.md +37 -26
- package/package.json +1 -1
package/docs/README.md
CHANGED
|
@@ -30,6 +30,6 @@ below are defined once, in [Words](guide/vocabulary.md#words).
|
|
|
30
30
|
| --- | --- |
|
|
31
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` |
|
|
32
32
|
| [Errors](guide/errors.md) | You are turning what the package throws into a status code, and want every code and what it carries |
|
|
33
|
-
| [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 |
|
|
34
34
|
| [Troubleshooting](troubleshooting.md) | You have an error message and want its cause and its fix |
|
|
35
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
|
|
|
@@ -74,6 +74,9 @@ readonly verifyEmail: {
|
|
|
74
74
|
`send` issues a token for the user's **current** e-mail. `confirm` redeems it
|
|
75
75
|
and sets `emailVerified`. A token sent to an e-mail the user has since changed
|
|
76
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.
|
|
77
80
|
Changing the e-mail with `update` sets `emailVerified` back to `false`.
|
|
78
81
|
|
|
79
82
|
## `resetPassword`
|
|
@@ -100,7 +103,10 @@ export async function forgotPassword(request: Request): Promise<Response> {
|
|
|
100
103
|
```
|
|
101
104
|
|
|
102
105
|
`confirm` sets the password, marks the e-mail verified — the link proved it —
|
|
103
|
-
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
|
|
104
110
|
if that is your policy. A password refused for its length does not spend the
|
|
105
111
|
token, so the visitor can try again with the same link.
|
|
106
112
|
|
|
@@ -203,6 +203,7 @@ 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
|
|
|
@@ -261,8 +262,8 @@ const signedIn = await auth.secondFactor.confirm(challenge, code);
|
|
|
261
262
|
| Rejects with | When | What to do |
|
|
262
263
|
| --- | --- | --- |
|
|
263
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 |
|
|
264
|
-
| `TOKEN_UNKNOWN` | no such challenge — a typo, another user type's, or one a store's TTL already dropped | sign in again |
|
|
265
|
-
| `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 |
|
|
266
267
|
| `TOKEN_EXPIRED` | `expiresAt` has passed | sign in again |
|
|
267
268
|
| `USER_INACTIVE` | the user was deactivated since `signIn`. The challenge is spent | answer 403, as `signIn` would |
|
|
268
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 |
|
|
@@ -292,6 +293,30 @@ Five attempts at a million values is a one-in-200,000 chance per password
|
|
|
292
293
|
guessed right. A new challenge takes a new sign-in, with the password, so the
|
|
293
294
|
attempts are bounded by your sign-in rate limit too.
|
|
294
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
|
+
|
|
295
320
|
### Lifetime
|
|
296
321
|
|
|
297
322
|
A challenge lives `'5m'` unless `secondFactor.challenge` says otherwise, and
|
|
@@ -181,10 +181,10 @@ hash, like a session token, so the store cannot give it back either.
|
|
|
181
181
|
|
|
182
182
|
A challenge belongs to the user type that issued it: confirm a patient's
|
|
183
183
|
challenge with `clinic.patient.signInCode.confirm`. Another type's `confirm`
|
|
184
|
-
answers `TOKEN_UNKNOWN
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
184
|
+
answers `TOKEN_UNKNOWN` and compares no code — but it has already cost one of
|
|
185
|
+
the challenge's five attempts, because the attempt is counted before the type
|
|
186
|
+
is known. The challenge is left for its own type, with one attempt fewer, and
|
|
187
|
+
the fifth such call spends it, as a fifth wrong code would.
|
|
188
188
|
|
|
189
189
|
## Confirming the code
|
|
190
190
|
|
|
@@ -221,8 +221,26 @@ try {
|
|
|
221
221
|
Five attempts at a million values is a one-in-200,000 chance per challenge.
|
|
222
222
|
Codes sent at once past the fifth attempt are all refused, the right one
|
|
223
223
|
included: the store counts, and nothing reads the count before writing it.
|
|
224
|
-
|
|
225
|
-
|
|
224
|
+
|
|
225
|
+
**At most one code is live per user.** A `request` issues its code, then
|
|
226
|
+
spends every other challenge of the user, so the code in an earlier e-mail
|
|
227
|
+
answers `TOKEN_SPENT` — only the last one works:
|
|
228
|
+
|
|
229
|
+
```ts
|
|
230
|
+
const first = await auth.signInCode.request(email);
|
|
231
|
+
const second = await auth.signInCode.request(email); // the visitor asked again
|
|
232
|
+
await auth.signInCode.confirm(first.challenge, first.code); // TOKEN_SPENT
|
|
233
|
+
await auth.signInCode.confirm(second.challenge, second.code); // signed in
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Requests that arrive at once cannot each keep a code: each spends the others'
|
|
237
|
+
once it issued its own, so at most one survives — sometimes none, and the
|
|
238
|
+
visitor asks again. Guesses therefore never run against two live challenges.
|
|
239
|
+
|
|
240
|
+
**Rate-limit `request` per address.** A new challenge takes only a new
|
|
241
|
+
`request`, and each one sends an e-mail and cancels the code before it:
|
|
242
|
+
without a limit, anyone who knows an address can fill its inbox, or keep
|
|
243
|
+
its owner from ever typing a code in time.
|
|
226
244
|
|
|
227
245
|
### Lifetime
|
|
228
246
|
|
|
@@ -270,8 +288,8 @@ answers `SignedIn`.
|
|
|
270
288
|
| Rejects with | When | What to do |
|
|
271
289
|
| --- | --- | --- |
|
|
272
290
|
| `CODE_INVALID`, with `attemptsLeft` | the code does not match, or is not six digits. Message: `signInCode.confirm: the code does not match, or was already used` | ask again while `attemptsLeft > 0`; at `0` the challenge is spent: request a new code |
|
|
273
|
-
| `TOKEN_UNKNOWN` | no such challenge — a typo, another user type's, one whose user was deleted, or one a store's TTL already dropped. Another type's challenge still loses one of its attempts | request a new code |
|
|
274
|
-
| `TOKEN_SPENT` | the challenge already signed someone in,
|
|
291
|
+
| `TOKEN_UNKNOWN` | no such challenge — a typo, another user type's, one whose user was deleted, or one a store's TTL already dropped. Another type's challenge still loses one of its attempts, and its fifth spends it | request a new code |
|
|
292
|
+
| `TOKEN_SPENT` | the challenge already signed someone in, its attempts ran out, or a newer `request` for the same user spent it — only the last code sent works | request a new code, and use the latest e-mail |
|
|
275
293
|
| `TOKEN_EXPIRED` | `expiresAt` has passed | request a new code |
|
|
276
294
|
| `TOKEN_STALE` | the user changed their e-mail since the code was sent. Message: `signInCode.confirm: the code was sent to an e-mail the user no longer has`. The challenge is spent | request a new code, to the current address |
|
|
277
295
|
| `USER_INACTIVE` | the user was deactivated since the code was sent. The challenge is spent | answer 403 |
|
package/docs/guide/users.md
CHANGED
|
@@ -185,10 +185,10 @@ With a `password`, besides:
|
|
|
185
185
|
| Method | Answers | Rejects with |
|
|
186
186
|
| --- | --- | --- |
|
|
187
187
|
| `signUp(fields & { password })` | `{ status: 'signedIn', user, session, token }` | `USER_INVALID`, `PASSWORD_TOO_SHORT`, `LOGIN_TAKEN` |
|
|
188
|
-
| `signIn({ [login]: string, password })` | `{ status: 'signedIn', user, session, token }` — or, with `secondFactor` configured and the user's factor active, `{ status: 'secondFactor', challenge, expiresAt }`: switch on `status` | `CREDENTIALS_INVALID`, `USER_INACTIVE`, `HASH_UNSUPPORTED` |
|
|
188
|
+
| `signIn({ [login]: string, password })` | `{ status: 'signedIn', user, session, token }` — or, with `secondFactor` configured and the user's factor active, `{ status: 'secondFactor', challenge, expiresAt, userId }`: switch on `status` | `CREDENTIALS_INVALID`, `USER_INACTIVE`, `HASH_UNSUPPORTED` |
|
|
189
189
|
| `findByLogin(login)` | the user, or `null`; the login is normalised first, and one holding a NUL or a lone surrogate is nobody's | |
|
|
190
|
-
| `setPassword(user, password, { ifVersion? })` | the user — an admin's call | `PASSWORD_TOO_SHORT` |
|
|
191
|
-
| `changePassword(user, { current, next }, { ifVersion? })` | the user — the user's own call | `CREDENTIALS_INVALID`, `PASSWORD_TOO_SHORT` |
|
|
190
|
+
| `setPassword(user, password, { ifVersion? })` | the user — an admin's call; spends the user's second-factor challenges still waiting, and signs nobody out | `PASSWORD_TOO_SHORT` |
|
|
191
|
+
| `changePassword(user, { current, next }, { ifVersion? })` | the user — the user's own call; spends the user's second-factor challenges still waiting | `CREDENTIALS_INVALID`, `PASSWORD_TOO_SHORT` |
|
|
192
192
|
|
|
193
193
|
With `secondFactor` configured, a type with a password also answers
|
|
194
194
|
`secondFactor.enroll`, `activate`, `disable` and `confirm` — see
|
package/docs/guide/vocabulary.md
CHANGED
|
@@ -51,6 +51,7 @@ either finds this row.
|
|
|
51
51
|
| **second factor** | What a user proves at sign-in beyond their password: a TOTP secret their authenticator app holds, `UserRecord.secondFactor`. **Enrolled** once `enroll` stored it, waiting for a first code (`confirmedAt: null`); **active** once `activate` accepted one — `hasSecondFactor`, and only then does `signIn` ask for a code | "2FA", "MFA", "OTP device"; "confirmed" for the state — to *confirm* is to redeem a challenge |
|
|
52
52
|
| **attempt** | One code tried against a challenge, counted by `countAttempt` in the token's `attempts` before the code is checked. A challenge takes five, a second factor's or a sign-in code's | "try"; "retry" is a repeated write, never a guess |
|
|
53
53
|
| **challenge** | A secret the visitor keeps, redeemed once with a code — which is what *confirm* means for it. `signIn` answers one instead of a session when the user's second factor is active, for `secondFactor.confirm`; `signInCode.request` answers one beside the sign-in code, for `signInCode.confirm`. Stored as its hash, as a one-time token of kind `secondFactor` or `signInCode`: five minutes or ten by default, and five attempts | "MFA token", "pending session", "ticket" |
|
|
54
|
+
| **spent** | A one-time token or a challenge that can no longer be redeemed — `spentAt` set, `TOKEN_SPENT`, and never unset. Spent by its redemption, by its fifth attempt (a wrong code, or a call through another user type's API), by a redemption after it expired, by a refusal that ends it (`USER_INACTIVE`, `TOKEN_STALE`, `SECOND_FACTOR_NOT_ENROLLED`), or by what ends it early: the next `signInCode.request` for the same user (at most one sign-in code is live at a time), or a password written by `resetPassword.confirm`, `setPassword` or `changePassword` (every second-factor challenge left waiting) | "used up", "invalidated"; "consumed" only in `consumeToken`'s name; "revoked" is a session's |
|
|
54
55
|
| **step** | The thirty-second period a TOTP code belongs to. `lastStep` is the step of the last code accepted, and only a later step counts: that is why a code is accepted once | "window", "period", "counter", in prose |
|
|
55
56
|
| **seal**, **sealing key** | To seal is to encrypt a TOTP secret — AES-256-GCM, bound to the user's id — before a store sees it. A sealing key is one `{ id, key }` of `secondFactor.keys`: the first seals, every one opens | "encrypt", "encryption key", "master key", "pepper" |
|
|
56
57
|
| **token** | Never alone in prose: a *session token* or a *one-time token*. The `tokens` store and the `TOKEN_*` codes are one-time tokens only | |
|
package/docs/roadmap.md
CHANGED
|
@@ -93,6 +93,18 @@ Nothing between releases.
|
|
|
93
93
|
The last ten, newest first, each with the version it came in. Everything
|
|
94
94
|
before is in the [CHANGELOG](../CHANGELOG.md).
|
|
95
95
|
|
|
96
|
+
- **At most one sign-in code live per user, and challenges that end when
|
|
97
|
+
they should, v0.7.0** — `signInCode.request` spends the codes sent before,
|
|
98
|
+
even when requests race, so only the last e-mail's works; writing a
|
|
99
|
+
password spends the second-factor challenges left waiting, and a sign-in
|
|
100
|
+
still running when it lands is refused; another user type's `confirm` spends a challenge
|
|
101
|
+
at its fifth attempt; `verifyEmail.confirm` and `resetPassword.confirm`
|
|
102
|
+
check the e-mail again on the record they write. `SecondFactorRequired`
|
|
103
|
+
carries `userId`, for logs and rate limits. For adapters:
|
|
104
|
+
`TokenStore.spendUserTokens(userId, kind, at, except?)`, with four new conformance
|
|
105
|
+
cases, implemented in `@nxgt/janus-drizzle`, `@nxgt/janus-mongo` and
|
|
106
|
+
`@nxgt/janus-redis` — and the port now says a read sees every write that
|
|
107
|
+
completed before it: never a secondary or a read replica.
|
|
96
108
|
- **Sign in with a code sent by e-mail, v0.6.0** —
|
|
97
109
|
`auth.<type>.signInCode.request(email)` answers a six-digit code to send
|
|
98
110
|
and a challenge to keep with the visitor, or `null` for nobody — never
|
|
@@ -149,7 +161,3 @@ before is in the [CHANGELOG](../CHANGELOG.md).
|
|
|
149
161
|
- **The conformance suite accepts a store with its own expiry** — a store
|
|
150
162
|
that drops a lapsed session at once, as a Redis TTL does, passes
|
|
151
163
|
`sessions.deleteUser`; one that still holds it must count it. — v0.2.0
|
|
152
|
-
- **A Hono integration** — [`@nxgt/janus-hono`](https://www.npmjs.com/package/@nxgt/janus-hono):
|
|
153
|
-
the session middleware, the cookie, a route guarded by a permission,
|
|
154
|
-
`bindJanus()` to bind the instances once, and every error as its status.
|
|
155
|
-
Its own 0.1.0, beside `@nxgt/janus` 0.1.3.
|
package/docs/troubleshooting.md
CHANGED
|
@@ -232,9 +232,11 @@ Also: `janus: store.<slot> is missing`, `janus: store must be an object with use
|
|
|
232
232
|
|
|
233
233
|
**When:** `janus({...})`, from JavaScript or with a store typed loosely. TypeScript refuses a partial store at compile time and names the method.
|
|
234
234
|
**Why:** `store` is `{ users, sessions, tokens }`, and each slot must answer every method of the port. `deleteExpiredSessions` is the one optional method: absent, or a function.
|
|
235
|
-
An adapter written
|
|
236
|
-
`store.tokens has no method countAttempt`
|
|
237
|
-
|
|
235
|
+
An adapter written for an earlier `@nxgt/janus` reports the method a later
|
|
236
|
+
release added to the port: `store.tokens has no method countAttempt` for one
|
|
237
|
+
written against 0.3 (the method came in 0.4), and
|
|
238
|
+
`store.tokens has no method spendUserTokens` for one written against 0.6 (the
|
|
239
|
+
method came in 0.7).
|
|
238
240
|
|
|
239
241
|
**Fix:** pass the three stores, whole:
|
|
240
242
|
|
|
@@ -244,17 +246,17 @@ import { createMemoryStores, janus } from '@nxgt/janus';
|
|
|
244
246
|
janus({ ..., store: createMemoryStores() });
|
|
245
247
|
```
|
|
246
248
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
`@nxgt/janus-redis` 0.2:
|
|
249
|
+
Upgrade the published adapter to the release that implements both —
|
|
250
|
+
`@nxgt/janus-drizzle` 0.3, `@nxgt/janus-mongo` 0.4, `@nxgt/janus-redis` 0.3:
|
|
250
251
|
|
|
251
252
|
```bash
|
|
252
|
-
bun add @nxgt/janus@^0.
|
|
253
|
+
bun add @nxgt/janus@^0.7 @nxgt/janus-drizzle@^0.3 # or @nxgt/janus-mongo@^0.4, @nxgt/janus-redis@^0.3
|
|
253
254
|
```
|
|
254
255
|
|
|
255
|
-
Your own adapter implements
|
|
256
|
-
[`TokenStore.countAttempt`](guide/adapters.md#tokenstorecountattempt)
|
|
257
|
-
|
|
256
|
+
Your own adapter implements them as
|
|
257
|
+
[`TokenStore.countAttempt`](guide/adapters.md#tokenstorecountattempt) and
|
|
258
|
+
[`TokenStore.spendUserTokens`](guide/adapters.md#tokenstorespendusertokens)
|
|
259
|
+
set out, then runs the conformance suite.
|
|
258
260
|
|
|
259
261
|
### `janus: relations must be a relation store — relations.deleteEntity is missing`
|
|
260
262
|
|
|
@@ -473,7 +475,7 @@ if (error instanceof UserInvalidError) {
|
|
|
473
475
|
Also `changePassword: the current password does not match`.
|
|
474
476
|
|
|
475
477
|
**When:** `signIn`, `changePassword`.
|
|
476
|
-
**Why:** no user holds the login, the user has no password, or the password is wrong — **one code for the three**. `error.reason` (`unknownLogin`, `noPassword`, `wrongPassword`) tells them apart for your logs and your rate limiter. A login holding a NUL character or a lone surrogate is `unknownLogin`: no user can hold one.
|
|
478
|
+
**Why:** no user holds the login, the user has no password, or the password is wrong — **one code for the three**. Also a sign-in that verified a password written over while it ran (`reason: 'wrongPassword'`): its session is revoked, or its challenge spent, before the refusal. `error.reason` (`unknownLogin`, `noPassword`, `wrongPassword`) tells them apart for your logs and your rate limiter. A login holding a NUL character or a lone surrogate is `unknownLogin`: no user can hold one.
|
|
477
479
|
**Fix:** answer 401 with the same body whatever the reason:
|
|
478
480
|
|
|
479
481
|
```ts
|
|
@@ -522,10 +524,14 @@ answered: `secondFactor.confirm: no such challenge`,
|
|
|
522
524
|
|
|
523
525
|
**When:** `secondFactor.confirm(challenge, code)`.
|
|
524
526
|
**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,
|
|
526
|
-
that ends it (`USER_INACTIVE`, `SECOND_FACTOR_NOT_ENROLLED`)
|
|
527
|
-
|
|
528
|
-
|
|
527
|
+
the code that opens the session, by the fifth wrong code, by a refusal
|
|
528
|
+
that ends it (`USER_INACTIVE`, `SECOND_FACTOR_NOT_ENROLLED`), and by a
|
|
529
|
+
password written — `resetPassword.confirm`, `setPassword`, `changePassword` —
|
|
530
|
+
which ends every sign-in left waiting on its code.
|
|
531
|
+
`TOKEN_UNKNOWN` also covers a challenge whose user was deleted, one
|
|
532
|
+
confirmed through another user type's `secondFactor`, and a challenge passed
|
|
533
|
+
where a code was expected — the two arguments swapped. Another type's
|
|
534
|
+
`confirm` still costs an attempt, and the fifth spends the challenge.
|
|
529
535
|
**Fix:** answer 400 and send the visitor back to sign in, which asks for a new
|
|
530
536
|
code. To give slower visitors more time:
|
|
531
537
|
|
|
@@ -540,15 +546,20 @@ janus({ ..., secondFactor: { issuer: 'Acme', keys, challenge: '10m' } });
|
|
|
540
546
|
|
|
541
547
|
**When:** `signInCode.confirm(challenge, code)`.
|
|
542
548
|
**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,
|
|
544
|
-
that ends it (`TOKEN_STALE`, `USER_INACTIVE`)
|
|
549
|
+
the code that signs the user in, by the fifth wrong code, by a refusal
|
|
550
|
+
that ends it (`TOKEN_STALE`, `USER_INACTIVE`), and by the next `request` for
|
|
551
|
+
the same user: only the last code sent works, so a visitor who asked twice
|
|
552
|
+
and typed the first code gets `TOKEN_SPENT` — and when two requests race,
|
|
553
|
+
even the last code can be spent: at most one survives, sometimes none. `TOKEN_UNKNOWN` also covers a
|
|
545
554
|
challenge whose user was deleted, one confirmed through another user type's
|
|
546
555
|
`signInCode`, the decoy challenge of a `request` that answered `null`, and
|
|
547
|
-
the two arguments swapped. Another type's `confirm` compares no code
|
|
548
|
-
|
|
556
|
+
the two arguments swapped. Another type's `confirm` compares no code, but it
|
|
557
|
+
has already cost one of the challenge's five
|
|
549
558
|
attempts: the attempt is counted before the type is known. The challenge is
|
|
550
|
-
left for its own type, with one attempt fewer
|
|
551
|
-
|
|
559
|
+
left for its own type, with one attempt fewer — and the fifth such call
|
|
560
|
+
spends it, as a fifth wrong code would.
|
|
561
|
+
**Fix:** answer 400 and offer to send a new code — and tell the visitor to
|
|
562
|
+
use the latest e-mail. To give slower inboxes
|
|
552
563
|
more time:
|
|
553
564
|
|
|
554
565
|
```ts
|
|
@@ -557,8 +568,8 @@ janus({ ..., tokens: { signInCode: '15m' } });
|
|
|
557
568
|
|
|
558
569
|
### `TOKEN_STALE` — `<call>: the token was sent to an e-mail the user no longer has`
|
|
559
570
|
|
|
560
|
-
**When:** `verifyEmail.confirm` or `resetPassword.confirm`, after the user changed their e-mail.
|
|
561
|
-
**Why:** confirming it would verify an address nobody holds any more. The token is spent.
|
|
571
|
+
**When:** `verifyEmail.confirm` or `resetPassword.confirm`, after the user changed their e-mail — even while the link was being redeemed: the address is checked again on the very record the write replaces.
|
|
572
|
+
**Why:** confirming it would verify an address nobody holds any more, or reset a password through one. The token is spent, and nothing is written.
|
|
562
573
|
**Fix:** send a new token to the current address: `await auth.verifyEmail.send(user)`.
|
|
563
574
|
|
|
564
575
|
### `INVALID_CURSOR` — `<call>: this cursor was not minted by this store, or was minted for another ordering (<n> characters)`
|
|
@@ -626,7 +637,7 @@ each of those entries has a paragraph for it.
|
|
|
626
637
|
The same for `session` and `user`.
|
|
627
638
|
|
|
628
639
|
**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.
|
|
640
|
+
**Why:** `signIn` then answers one of two shapes: `{ status: 'signedIn', user, session, token }`, or `{ status: 'secondFactor', challenge, expiresAt, userId }` for a user whose second factor is active — the password alone opens no session for them. Without `secondFactor`, `signIn` still answers a session.
|
|
630
641
|
**Fix:** switch on `status`:
|
|
631
642
|
|
|
632
643
|
```ts
|
|
@@ -849,8 +860,8 @@ form field now holds the second challenge, so the first code does not
|
|
|
849
860
|
match it — and costs an attempt.
|
|
850
861
|
**Fix:** tell the visitor that only the last code sent works, and put the
|
|
851
862
|
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
|
|
853
|
-
own
|
|
863
|
+
e-mails apart. The earlier challenge is spent by the new `request`: the
|
|
864
|
+
first code, even with its own challenge, answers `TOKEN_SPENT`.
|
|
854
865
|
|
|
855
866
|
### `signInCode.request` answers `null` for a user who exists
|
|
856
867
|
|