@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.
- package/README.md +135 -15
- package/dist/auth/config.d.ts +25 -1
- package/dist/auth/config.d.ts.map +1 -1
- package/dist/auth/context.d.ts.map +1 -1
- package/dist/auth/index.d.ts +4 -3
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/one-time.d.ts +39 -0
- package/dist/auth/one-time.d.ts.map +1 -0
- package/dist/auth/port/types.d.ts +74 -3
- package/dist/auth/port/types.d.ts.map +1 -1
- package/dist/auth/sealing.d.ts +39 -0
- package/dist/auth/sealing.d.ts.map +1 -0
- package/dist/auth/second-factor/challenge.d.ts +20 -0
- package/dist/auth/second-factor/challenge.d.ts.map +1 -0
- package/dist/auth/second-factor/factor.d.ts +30 -0
- package/dist/auth/second-factor/factor.d.ts.map +1 -0
- package/dist/auth/second-factor/flows.d.ts +19 -0
- package/dist/auth/second-factor/flows.d.ts.map +1 -0
- package/dist/auth/second-factor/lifecycle.d.ts +8 -0
- package/dist/auth/second-factor/lifecycle.d.ts.map +1 -0
- package/dist/auth/sessions.d.ts.map +1 -1
- package/dist/auth/totp.d.ts +36 -0
- package/dist/auth/totp.d.ts.map +1 -0
- package/dist/auth/types.d.ts +91 -7
- 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-qwfkhqkk.js → index-06vp9c5r.js} +12 -3
- package/dist/chunks/{index-qwfkhqkk.js.map → index-06vp9c5r.js.map} +3 -3
- package/dist/chunks/{index-53y1afjz.js → index-5vr13kkb.js} +2 -2
- package/dist/chunks/{index-mgh85djb.js → index-c4v27jfr.js} +2 -2
- package/dist/chunks/{index-8tksqzkr.js → index-gbwn2tts.js} +14 -3
- package/dist/chunks/{index-8tksqzkr.js.map → index-gbwn2tts.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/cases/users.d.ts.map +1 -1
- package/dist/conformance/fixtures.d.ts.map +1 -1
- package/dist/conformance/index.js +122 -5
- package/dist/conformance/index.js.map +6 -6
- package/dist/errors/janus-error.d.ts +28 -2
- package/dist/errors/janus-error.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +390 -40
- package/dist/index.js.map +15 -8
- package/dist/permissions/index.js +3 -3
- package/docs/README.md +2 -1
- package/docs/guide/adapters.md +129 -3
- package/docs/guide/errors.md +18 -6
- package/docs/guide/second-factor.md +569 -0
- package/docs/guide/sessions.md +18 -0
- package/docs/guide/users.md +8 -2
- package/docs/guide/vocabulary.md +6 -1
- package/docs/roadmap.md +33 -46
- package/docs/troubleshooting.md +276 -5
- package/package.json +1 -1
- /package/dist/chunks/{index-53y1afjz.js.map → index-5vr13kkb.js.map} +0 -0
- /package/dist/chunks/{index-mgh85djb.js.map → index-c4v27jfr.js.map} +0 -0
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 + 6 (+ 1 optional) +
|
|
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? })` |
|
|
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**:
|
package/docs/guide/errors.md
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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,
|
|
123
|
-
|
|
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.
|