@nxgt/janus 0.24.0 → 0.25.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 +24 -10
- package/dist/auth/outage.d.ts +2 -2
- package/dist/auth/port/assert-stores.d.ts.map +1 -1
- package/dist/auth/port/memory/tokens.d.ts.map +1 -1
- package/dist/auth/port/types/stores.d.ts +2 -0
- package/dist/auth/port/types/stores.d.ts.map +1 -1
- package/dist/auth/port/types/tokens.d.ts +12 -0
- package/dist/auth/port/types/tokens.d.ts.map +1 -1
- package/dist/auth/sessions/collect-expired.d.ts +14 -0
- package/dist/auth/sessions/collect-expired.d.ts.map +1 -0
- package/dist/auth/sessions/shared-api.d.ts +1 -1
- package/dist/auth/sessions/shared-api.d.ts.map +1 -1
- package/dist/auth/types/janus.d.ts +4 -3
- package/dist/auth/types/janus.d.ts.map +1 -1
- package/dist/chunks/{index-6bjfv4xv.js → index-q3w3vr69.js} +13 -3
- package/dist/chunks/{index-6bjfv4xv.js.map → index-q3w3vr69.js.map} +3 -3
- package/dist/chunks/{index-cntabs9d.js → index-rpp0bw6e.js} +2 -2
- package/dist/chunks/{index-kppkcbn5.js → index-spwh8b9z.js} +2 -5
- package/dist/chunks/{index-kppkcbn5.js.map → index-spwh8b9z.js.map} +2 -2
- package/dist/chunks/{index-4khsm7mw.js → index-y9b327j8.js} +18 -18
- package/dist/chunks/{index-4khsm7mw.js.map → index-y9b327j8.js.map} +5 -5
- package/dist/conformance/cases/outage.d.ts.map +1 -1
- package/dist/conformance/cases/tokens/deletion.d.ts.map +1 -1
- package/dist/conformance/describe.d.ts +1 -0
- package/dist/conformance/describe.d.ts.map +1 -1
- package/dist/conformance/index.js +39 -5
- package/dist/conformance/index.js.map +5 -5
- package/dist/conformance/types.d.ts +1 -1
- package/dist/conformance/types.d.ts.map +1 -1
- package/dist/index.js +19 -9
- package/dist/index.js.map +7 -6
- package/dist/permissions/index.js +3 -3
- package/docs/guide/adapters.md +40 -3
- package/docs/guide/email-flows.md +2 -2
- package/docs/guide/errors.md +1 -1
- package/docs/guide/passwords.md +7 -10
- package/docs/guide/sessions.md +15 -4
- package/docs/roadmap.md +8 -8
- package/docs/troubleshooting.md +6 -6
- package/package.json +1 -1
- /package/dist/chunks/{index-cntabs9d.js.map → index-rpp0bw6e.js.map} +0 -0
|
@@ -4,16 +4,16 @@ import {
|
|
|
4
4
|
UNSTORABLE,
|
|
5
5
|
MAX_PAGE_SIZE2,
|
|
6
6
|
pageLimit2
|
|
7
|
-
} from "../chunks/index-
|
|
7
|
+
} from "../chunks/index-y9b327j8.js";
|
|
8
8
|
import {
|
|
9
9
|
PermissionDepthError2,
|
|
10
10
|
isSubjectSet2,
|
|
11
11
|
isSetOf2,
|
|
12
12
|
formatEntity2
|
|
13
|
-
} from "../chunks/index-
|
|
13
|
+
} from "../chunks/index-spwh8b9z.js";
|
|
14
14
|
import {
|
|
15
15
|
createMemoryRelations2
|
|
16
|
-
} from "../chunks/index-
|
|
16
|
+
} from "../chunks/index-rpp0bw6e.js";
|
|
17
17
|
|
|
18
18
|
// src/permissions/resolve/resolved.ts
|
|
19
19
|
function admits(holders, subject) {
|
package/docs/guide/adapters.md
CHANGED
|
@@ -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 (+ 1 optional) + 7 (+ 1 optional) + 5 |
|
|
34
|
+
| `JanusStores` — `{ users: UserStore, sessions: SessionStore, tokens: TokenStore }` | `janus({ store })` | 6 (+ 1 optional) + 7 (+ 1 optional) + 5 (+ 1 optional) |
|
|
35
35
|
| `RelationStore` | `permissions({ store })`, `janus({ relations })` | 6 |
|
|
36
36
|
|
|
37
37
|
They are separate on purpose: an application that only authenticates
|
|
@@ -184,6 +184,7 @@ interface TokenStore {
|
|
|
184
184
|
countAttempt(tokenHash: string, kind: TokenKind): Promise<TokenRecord | null>;
|
|
185
185
|
spendUserTokens(userId: Id, kind: TokenKind, at: Date, except?: string): Promise<number>; // the unspent ones, but except
|
|
186
186
|
deleteUserTokens(userId: Id): Promise<number>;
|
|
187
|
+
deleteExpiredTokens?(before: Date): Promise<number>; // optional: omit it if the database expires on its own
|
|
187
188
|
}
|
|
188
189
|
```
|
|
189
190
|
|
|
@@ -368,6 +369,41 @@ of the right `kind` whose `spentAt` is empty, skipping the one named by
|
|
|
368
369
|
`except`. `spendUserTokens` has its own
|
|
369
370
|
outage case.
|
|
370
371
|
|
|
372
|
+
### `TokenStore.deleteExpiredTokens` (optional)
|
|
373
|
+
|
|
374
|
+
What `collectExpired()` asks for, beside `SessionStore.deleteExpiredSessions`:
|
|
375
|
+
**delete every token whose `expiresAt` is at or before `before`**, and answer
|
|
376
|
+
how many. Leave it out when your database expires records on its own (a TTL
|
|
377
|
+
index, a key TTL) — nothing breaks, the core compares `expiresAt` on every
|
|
378
|
+
redemption — and implement it when it does not, or the rows pile up.
|
|
379
|
+
|
|
380
|
+
| The stored token | Written |
|
|
381
|
+
| --- | --- |
|
|
382
|
+
| `expiresAt` at or before `before`, spent or not, of any kind and any user | deleted, and counted |
|
|
383
|
+
| `expiresAt` later than `before`, spent or not | **kept** |
|
|
384
|
+
| none at all | `0`, an absence — never a failure |
|
|
385
|
+
|
|
386
|
+
**Never delete a token whose `expiresAt` is later, spent or not**: a spent
|
|
387
|
+
token is kept, answered as spent, until it lapses, or a count would start
|
|
388
|
+
over. That includes the throttles' counts — ordinary `secondFactor` tokens
|
|
389
|
+
under a keyed-hash user id, which `deleteUserTokens` never reaches and this
|
|
390
|
+
method is the only thing to collect. The core calls it with `clock.now()`, no
|
|
391
|
+
margin: a link collected answers `TOKEN_UNKNOWN`.
|
|
392
|
+
|
|
393
|
+
```ts
|
|
394
|
+
import type { TokenStore } from '@nxgt/janus';
|
|
395
|
+
|
|
396
|
+
// PostgreSQL through Drizzle — one statement, over an index on expires_at
|
|
397
|
+
deleteExpiredTokens: async (before) =>
|
|
398
|
+
(await db.delete(tokens).where(lte(tokens.expiresAt, before)).returning({ tokenHash: tokens.tokenHash })).length,
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
`janus()` reads its presence once (`StoreCapabilities.collectExpiredTokens`),
|
|
402
|
+
and refuses a `deleteExpiredTokens` that is there but not a function with a
|
|
403
|
+
`TypeError`. The suite's `tokens.deleteExpired` and `outage.deleteExpiredTokens`
|
|
404
|
+
cases check it, and are skipped with their reason for a store that leaves it
|
|
405
|
+
out. A failure throws, as everywhere on the port.
|
|
406
|
+
|
|
371
407
|
### `RelationStore`
|
|
372
408
|
|
|
373
409
|
```ts
|
|
@@ -449,7 +485,7 @@ compile error naming the missing method.
|
|
|
449
485
|
|
|
450
486
|
| Suite | Cases | Harness opens |
|
|
451
487
|
| --- | --- | --- |
|
|
452
|
-
| `describeJanusStores({ name, harness, runner?, faults?, skip? })` |
|
|
488
|
+
| `describeJanusStores({ name, harness, runner?, faults?, skip? })` | 61: users, sessions, tokens, and one outage per method whose honest answer can be "nothing" — sixteen of them | `{ stores, faults?, close? }` |
|
|
453
489
|
| `describeRelationStores({ name, harness, runner?, faults?, skip? })` | 16: the relation store, ids of edge characters, and one outage per method | `{ store, faults?, close? }` |
|
|
454
490
|
|
|
455
491
|
`harness.open()` is called **once per case** and must answer fresh, empty
|
|
@@ -546,7 +582,8 @@ describeJanusStores({
|
|
|
546
582
|
|
|
547
583
|
A skipped case still appears in the run, with its reason in its name. A case
|
|
548
584
|
that cannot run on the stores it was given — an outage case with no `faults`,
|
|
549
|
-
`
|
|
585
|
+
`sessions.deleteExpired` on a store without `deleteExpiredSessions`,
|
|
586
|
+
`tokens.deleteExpired` on one without `deleteExpiredTokens`, a `findUsers`
|
|
550
587
|
case on a store without `findUsers` — passes, and emits a
|
|
551
588
|
`JANUS_CONFORMANCE_SKIPPED` warning with the reason. A case's `needs` says what
|
|
552
589
|
it cannot run without — one `CaseNeed` or a list: `outage.findUsers` needs
|
|
@@ -511,8 +511,8 @@ answers again. A refused request is counted too but cannot extend the wait.
|
|
|
511
511
|
**Where the counts live.** In the tokens store, as `secondFactor` tokens named
|
|
512
512
|
by a keyed hash of the flow and the address or user id: no port change, no new
|
|
513
513
|
infrastructure, and never the address. That is one row per flow per address or
|
|
514
|
-
user per window.
|
|
515
|
-
|
|
514
|
+
user per window. `collectExpired()` collects the lapsed ones on a tokens store that implements
|
|
515
|
+
`deleteExpiredTokens`; a store with a TTL expires them on its own. A flushed or evicting
|
|
516
516
|
Redis forgets the counts, and a tokens store that cannot count throws
|
|
517
517
|
`STORE_FAILED` with nothing issued: it fails closed.
|
|
518
518
|
|
package/docs/guide/errors.md
CHANGED
|
@@ -64,7 +64,7 @@ that wired the library, so no handler needs to tell it apart.
|
|
|
64
64
|
| `SECOND_FACTOR_NOT_ENROLLED` | `SecondFactorError` | 409 | `activate` before `enroll`; `regenerateRecoveryCodes` without an active factor; `confirm` or `recover` after the factor was disabled | `userId` |
|
|
65
65
|
| `SECOND_FACTOR_ACTIVE` | `SecondFactorError` | 409 | `enroll` or `activate` on a factor already active: `disable` it first | `userId` |
|
|
66
66
|
| `INVALID_CURSOR` | `InvalidCursorError` | 400 | A cursor this store did not mint. Never a silent first page | |
|
|
67
|
-
| `UNSUPPORTED` | `UnsupportedError` | 501 | The wired store lacks an optional capability — `collectExpired`
|
|
67
|
+
| `UNSUPPORTED` | `UnsupportedError` | 501 | The wired store lacks an optional capability — `collectExpired` with neither `deleteExpiredSessions` nor `deleteExpiredTokens` | `slot`, `operation` |
|
|
68
68
|
| `PERMISSION_DEPTH` | `PermissionDepthError` | 500 | A check or list walked past `maxDepth`. **Not a denial** | `permission`, `maxDepth` |
|
|
69
69
|
|
|
70
70
|
Every class extends `JanusError`, which extends `Error`, so no `catch` block
|
package/docs/guide/passwords.md
CHANGED
|
@@ -195,16 +195,13 @@ counts correctly. Each is named by a keyed hash of the login, never the login
|
|
|
195
195
|
otherwise, which only keeps the login out of plain sight: a dump of the
|
|
196
196
|
tokens then tells which logins were tried, for a guessed list. Each expires
|
|
197
197
|
two windows after its window starts — half an hour by default — **Redis and MongoDB drop it then;
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
`tokens` has no index on `expires_at`, so the delete scans the table: add
|
|
207
|
-
`create index on tokens (expires_at)` in a migration of yours if it grows.
|
|
198
|
+
a store with no TTL keeps it** until `collectExpired()` deletes it (the
|
|
199
|
+
tokens store's optional `deleteExpiredTokens`), and every login tried,
|
|
200
|
+
registered or not, adds a row per window. Schedule `collectExpired()` on such
|
|
201
|
+
a store: `@nxgt/janus-drizzle` implements `deleteExpiredTokens` (since 0.9),
|
|
202
|
+
with an index on `expires_at` that its
|
|
203
|
+
[migration](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus-drizzle/docs/guide/migrations.md#to-09-an-index-on-tokensexpires_at)
|
|
204
|
+
adds. A store of your own implements the method, or its rows stay.
|
|
208
205
|
|
|
209
206
|
The key is your first `secondFactor` key: wiring
|
|
210
207
|
`secondFactor` for the first time, or putting a new key first, starts every
|
package/docs/guide/sessions.md
CHANGED
|
@@ -329,10 +329,21 @@ a link or a code already in the person's inbox still works afterwards.
|
|
|
329
329
|
const removed: number = await auth.collectExpired();
|
|
330
330
|
```
|
|
331
331
|
|
|
332
|
-
Deletes lapsed sessions
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
332
|
+
Deletes lapsed sessions and lapsed one-time tokens, from the stores that keep
|
|
333
|
+
them, and answers how many in all. The tokens include the sign-in and mail
|
|
334
|
+
throttles' counts, which are ordinary tokens. It runs whichever of the
|
|
335
|
+
optional `sessions.deleteExpiredSessions` and `tokens.deleteExpiredTokens` the
|
|
336
|
+
stores implement, sessions first, and rejects with `UNSUPPORTED` only when
|
|
337
|
+
neither does — a store with its own TTL, such as `@nxgt/janus-mongo`'s, does
|
|
338
|
+
not implement them. The reference store implements both.
|
|
339
|
+
|
|
340
|
+
A token is deleted once its `expiresAt` is at or before now, spent or not;
|
|
341
|
+
one that has not lapsed is never deleted, so a throttle keeps counting and a
|
|
342
|
+
link still redeems until its time is up. After a token is collected, its link
|
|
343
|
+
answers `TOKEN_UNKNOWN` rather than `TOKEN_EXPIRED` or `TOKEN_SPENT`, as it
|
|
344
|
+
already does on a store with a TTL. A failure in the tokens store, after the
|
|
345
|
+
sessions went, is `STORE_FAILED`; both deletions are idempotent, so call it
|
|
346
|
+
again.
|
|
336
347
|
|
|
337
348
|
## Testing expiry
|
|
338
349
|
|
package/docs/roadmap.md
CHANGED
|
@@ -5,7 +5,7 @@ dates here, and the version something shipped in is the only number.
|
|
|
5
5
|
|
|
6
6
|
## Now
|
|
7
7
|
|
|
8
|
-
Nothing
|
|
8
|
+
Nothing yet.
|
|
9
9
|
|
|
10
10
|
## Next
|
|
11
11
|
|
|
@@ -13,13 +13,6 @@ Nothing yet.
|
|
|
13
13
|
|
|
14
14
|
## Later
|
|
15
15
|
|
|
16
|
-
- **Collecting lapsed one-time tokens** — `collectExpired()` deletes lapsed
|
|
17
|
-
sessions only. On a store with no TTL (PostgreSQL), lapsed tokens stay
|
|
18
|
-
until you delete them, and the sign-in throttle adds one per login tried
|
|
19
|
-
per window, and the mail throttle one per flow per address or user per
|
|
20
|
-
window. A port method to collect them, as sessions are, would let the
|
|
21
|
-
core do it.
|
|
22
|
-
|
|
23
16
|
- **A batch read across types** — `findMany` reads one user type, as `find`
|
|
24
17
|
does. The shared `findUser(id)` has no batch counterpart yet; one on the
|
|
25
18
|
instance, answering a union narrowed by `user.type`, would serve a list that
|
|
@@ -76,6 +69,13 @@ Nothing yet.
|
|
|
76
69
|
The last ten, newest first, each with the version it came in. Everything
|
|
77
70
|
before is in the [CHANGELOG](../CHANGELOG.md).
|
|
78
71
|
|
|
72
|
+
- **Collecting lapsed one-time tokens, v0.25.0** — `collectExpired()` deletes
|
|
73
|
+
lapsed sessions and, through a new optional `TokenStore.deleteExpiredTokens`,
|
|
74
|
+
lapsed one-time tokens too: the sign-in and mail throttles' counts
|
|
75
|
+
included, which a store with no TTL (PostgreSQL) kept for ever, one per
|
|
76
|
+
login tried per window and one per flow per address or user per window.
|
|
77
|
+
The reference store and `@nxgt/janus-drizzle` 0.9 implement it; MongoDB and
|
|
78
|
+
Redis expire tokens themselves and do not.
|
|
79
79
|
- **Finding a user, or creating them, v0.24.0** —
|
|
80
80
|
`auth.<type>.findOrCreate(key, input)` answers `{ user, created }`: the
|
|
81
81
|
holder of the key — the login on a type with a password, the e-mail on one
|
package/docs/troubleshooting.md
CHANGED
|
@@ -75,7 +75,7 @@ How the messages are shaped:
|
|
|
75
75
|
- [`TOKEN_STALE` — `<call>: the token was sent to an e-mail the user no longer has`](#token_stale--call-the-token-was-sent-to-an-e-mail-the-user-no-longer-has)
|
|
76
76
|
- [`INVALID_CURSOR` — `<call>: this cursor was not minted by this store …`](#invalid_cursor--call-this-cursor-was-not-minted-by-this-store-or-was-minted-for-another-ordering-n-characters)
|
|
77
77
|
- [`<call>: limit must be an integer of at least 1, or absent`](#call-limit-must-be-an-integer-of-at-least-1-or-absent)
|
|
78
|
-
- [`UNSUPPORTED` — `collectExpired: store.sessions does not implement deleteExpiredSessions …`](#unsupported--collectexpired-storesessions-does-not-implement-deleteexpiredsessions--
|
|
78
|
+
- [`UNSUPPORTED` — `collectExpired: store.sessions does not implement deleteExpiredSessions, nor store.tokens deleteExpiredTokens …`](#unsupported--collectexpired-storesessions-does-not-implement-deleteexpiredsessions-nor-storetokens-deleteexpiredtokens--their-stores-expire-records-on-their-own-or-implement-the-methods)
|
|
79
79
|
- [`<call>: the <type> type does not sign in with a password …`](#call-the-type-type-does-not-sign-in-with-a-password--add-password--login--to-it)
|
|
80
80
|
- [`authenticate()` answers `null` although a valid cookie was sent](#authenticate-answers-null-although-a-valid-cookie-was-sent)
|
|
81
81
|
|
|
@@ -297,10 +297,10 @@ janus({ ..., hasher: scryptHasher(), verifiers: [legacyBcrypt] }); // not [scryp
|
|
|
297
297
|
|
|
298
298
|
### `janus: store.<slot> has no method <method>, which the port requires`
|
|
299
299
|
|
|
300
|
-
Also: `janus: store.<slot> is missing`, `janus: store must be an object with users, sessions and tokens`, `janus: store.sessions.deleteExpiredSessions must be a function or absent`, `janus: store.users.findUsers must be a function or absent`.
|
|
300
|
+
Also: `janus: store.<slot> is missing`, `janus: store must be an object with users, sessions and tokens`, `janus: store.sessions.deleteExpiredSessions must be a function or absent`, `janus: store.tokens.deleteExpiredTokens must be a function or absent`, `janus: store.users.findUsers must be a function or absent`.
|
|
301
301
|
|
|
302
302
|
**When:** `janus({...})`, from JavaScript or with a store typed loosely. TypeScript refuses a partial store at compile time and names the method.
|
|
303
|
-
**Why:** `store` is `{ users, sessions, tokens }`, and each slot must answer every method of the port.
|
|
303
|
+
**Why:** `store` is `{ users, sessions, tokens }`, and each slot must answer every method of the port. Three methods are optional — `sessions.deleteExpiredSessions`, `tokens.deleteExpiredTokens` and `users.findUsers` — and each is absent, or a function: a value in its place (`findUsers: true`, a property copied from a config) is a wiring mistake, not an absent capability.
|
|
304
304
|
An adapter written for an earlier `@nxgt/janus` reports the method a later
|
|
305
305
|
release added to the port: `store.tokens has no method countAttempt` for one
|
|
306
306
|
written against 0.3 (the method came in 0.4), and
|
|
@@ -1069,11 +1069,11 @@ const page = await access.list(user, 'view', 'record', {
|
|
|
1069
1069
|
});
|
|
1070
1070
|
```
|
|
1071
1071
|
|
|
1072
|
-
### `UNSUPPORTED` — `collectExpired: store.sessions does not implement deleteExpiredSessions —
|
|
1072
|
+
### `UNSUPPORTED` — `collectExpired: store.sessions does not implement deleteExpiredSessions, nor store.tokens deleteExpiredTokens — their stores expire records on their own, or implement the methods`
|
|
1073
1073
|
|
|
1074
1074
|
**When:** `auth.collectExpired()`.
|
|
1075
|
-
**Why:** the sessions store
|
|
1076
|
-
**Fix:** do not schedule `collectExpired` for
|
|
1075
|
+
**Why:** neither the sessions store implements the optional `deleteExpiredSessions` nor the tokens store the optional `deleteExpiredTokens` — usually because they expire records themselves (a MongoDB TTL index, a Redis `EXPIRE`). With one of the two, `collectExpired` collects that one and does not throw.
|
|
1076
|
+
**Fix:** do not schedule `collectExpired` for those stores. Expiry does not depend on it: the core compares `expiresAt` on every `authenticate` and on every redemption. If your database has no TTL, implement the methods ([adapters](guide/adapters.md#tokenstoredeleteexpiredtokens-optional)).
|
|
1077
1077
|
|
|
1078
1078
|
### `<call>: the <type> type does not sign in with a password — add password: { login } to it`
|
|
1079
1079
|
|
package/package.json
CHANGED
|
File without changes
|