@nxgt/janus 0.4.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 +102 -18
- 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 +3 -2
- 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 +10 -7
- 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-thtyq7a9.js → index-gbwn2tts.js} +2 -2
- package/dist/conformance/cases/tokens.d.ts.map +1 -1
- package/dist/conformance/index.js +15 -4
- package/dist/conformance/index.js.map +3 -3
- 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 +388 -41
- package/dist/index.js.map +14 -7
- package/dist/permissions/index.js +3 -3
- package/docs/README.md +1 -0
- package/docs/guide/adapters.md +12 -7
- 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 -3
- package/docs/roadmap.md +26 -48
- package/docs/troubleshooting.md +260 -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/dist/chunks/{index-thtyq7a9.js.map → index-gbwn2tts.js.map} +0 -0
package/docs/guide/adapters.md
CHANGED
|
@@ -97,10 +97,12 @@ interface UserRecord {
|
|
|
97
97
|
}
|
|
98
98
|
```
|
|
99
99
|
|
|
100
|
-
`secret` is **opaque to a store**:
|
|
101
|
-
|
|
102
|
-
dump of the users cannot produce a code. Keep it
|
|
103
|
-
for byte, no parsing, no trimming.
|
|
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
|
|
104
106
|
the second factor whole: a method without a secret, or a `lastStep` without a
|
|
105
107
|
method, is a record the core never writes.
|
|
106
108
|
|
|
@@ -150,7 +152,7 @@ interface TokenRecord {
|
|
|
150
152
|
readonly tokenHash: string;
|
|
151
153
|
readonly kind: TokenKind;
|
|
152
154
|
readonly userId: Id;
|
|
153
|
-
readonly address: string;
|
|
155
|
+
readonly address: string; // '' for a secondFactor challenge: nothing was sent
|
|
154
156
|
readonly codeHash: string | null; // a signInCode's code, hashed; null for every other kind
|
|
155
157
|
readonly attempts: number; // 0 at insertion
|
|
156
158
|
readonly expiresAt: Date;
|
|
@@ -160,7 +162,9 @@ interface TokenRecord {
|
|
|
160
162
|
```
|
|
161
163
|
|
|
162
164
|
A token redeemed for another kind is unknown: every method that takes a
|
|
163
|
-
`kind` matches on it. `
|
|
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
|
|
164
168
|
field. An adapter whose stored tokens predate them reads them as `null` and
|
|
165
169
|
`0`, as the three published adapters do, so no data migration is needed for
|
|
166
170
|
them.
|
|
@@ -294,7 +298,7 @@ compile error naming the missing method.
|
|
|
294
298
|
|
|
295
299
|
| Suite | Cases | Harness opens |
|
|
296
300
|
| --- | --- | --- |
|
|
297
|
-
| `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? }` |
|
|
298
302
|
| `describeRelationStores({ name, harness, runner?, faults?, skip? })` | 15: the relation store, and one outage per method | `{ store, faults?, close? }` |
|
|
299
303
|
|
|
300
304
|
`harness.open()` is called **once per case** and must answer fresh, empty
|
|
@@ -320,6 +324,7 @@ while you work on it, never to ship:
|
|
|
320
324
|
| `tokens.countAttempt` | two calls answer `attempts` 1 then 2, `codeHash` as written; `consumeToken` answers the count |
|
|
321
325
|
| `tokens.countAttemptConcurrency` | twenty concurrent calls answer 1 to 20, each once |
|
|
322
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 |
|
|
323
328
|
| `tokens.countAttemptSpent` | a spent token answered unchanged; another kind and an unknown hash answer `null` and count nothing |
|
|
324
329
|
| `outage.countAttempt` | a store that cannot answer rejects, never `null` |
|
|
325
330
|
|
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.
|