@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/roadmap.md
CHANGED
|
@@ -5,14 +5,17 @@ dates here, and the version something shipped in is the only number.
|
|
|
5
5
|
|
|
6
6
|
## Now
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
- **One-time codes** — a one-time token short enough to type, sent by e-mail
|
|
9
|
+
to sign in without a password, or to confirm a sensitive action, issued
|
|
10
|
+
and redeemed by `janus` like the verification and reset tokens today. The
|
|
11
|
+
TOTP second factor shipped in v0.5.0, below, on the store port that
|
|
12
|
+
shipped in v0.4.0.
|
|
9
13
|
|
|
10
14
|
## Next
|
|
11
15
|
|
|
12
|
-
- **
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
TOTP, the one-time codes of an authenticator app, as a second factor.
|
|
16
|
+
- **Recovery codes** — single-use codes for the TOTP second factor, so a
|
|
17
|
+
user who loses their authenticator app can still sign in, without an
|
|
18
|
+
operator resetting the account.
|
|
16
19
|
- **Sending the e-mails** — in a package of its own, `@nxgt/janus-mail`, built
|
|
17
20
|
on a general mail toolkit shared with applications that are not about
|
|
18
21
|
sign-in: a `Mailer` port you plug your transport into (SMTP, Resend, SES…) —
|
|
@@ -42,8 +45,10 @@ Nothing yet.
|
|
|
42
45
|
- **More official adapters** — the ports are cut where atomicity is not
|
|
43
46
|
required, so users, sessions and permission tuples can each live in the
|
|
44
47
|
database that suits them. MongoDB is the first adapter
|
|
45
|
-
([`@nxgt/janus-mongo`](https://www.npmjs.com/package/@nxgt/janus-mongo))
|
|
46
|
-
PostgreSQL
|
|
48
|
+
([`@nxgt/janus-mongo`](https://www.npmjs.com/package/@nxgt/janus-mongo)),
|
|
49
|
+
PostgreSQL and Redis followed
|
|
50
|
+
([`@nxgt/janus-drizzle`](https://www.npmjs.com/package/@nxgt/janus-drizzle),
|
|
51
|
+
[`@nxgt/janus-redis`](https://www.npmjs.com/package/@nxgt/janus-redis)).
|
|
47
52
|
|
|
48
53
|
## Not planned
|
|
49
54
|
|
|
@@ -82,8 +87,24 @@ Nothing yet.
|
|
|
82
87
|
|
|
83
88
|
## Shipped
|
|
84
89
|
|
|
85
|
-
|
|
90
|
+
The last ten, newest first, each with the version it came in. Everything
|
|
91
|
+
before is in the [CHANGELOG](../CHANGELOG.md).
|
|
86
92
|
|
|
93
|
+
- **A TOTP second factor, v0.5.0** — `janus({ secondFactor: { issuer, keys } })`
|
|
94
|
+
and `auth.<type>.secondFactor`'s `enroll`, `activate`, `disable` and
|
|
95
|
+
`confirm`, for every user type with a password; each secret sealed with
|
|
96
|
+
AES-256-GCM under keys your application holds, and rotated by adding a key.
|
|
97
|
+
Breaking once configured: `signIn` answers `{ status: 'signedIn', … }` or
|
|
98
|
+
`{ status: 'secondFactor', challenge, expiresAt }`. A challenge lives five
|
|
99
|
+
minutes and takes five attempts, a code is accepted once, and a refused one
|
|
100
|
+
throws `CODE_INVALID` with `attemptsLeft`.
|
|
101
|
+
- **The store port holds a second factor and counts attempts on a token,
|
|
102
|
+
v0.4.0** — `UserRecord.secondFactor`, a token's `codeHash` and `attempts`,
|
|
103
|
+
the token kinds `'secondFactor'` and `'signInCode'`, and
|
|
104
|
+
`TokenStore.countAttempt`, one conditional write per attempt, with six new
|
|
105
|
+
conformance cases. The published adapters implement them in
|
|
106
|
+
`@nxgt/janus-drizzle` 0.2, `@nxgt/janus-mongo` 0.3 and
|
|
107
|
+
`@nxgt/janus-redis` 0.2.
|
|
87
108
|
- **Permissions on a user, v0.3.0** — a user type may also be an object type:
|
|
88
109
|
a staff member is the object `can()` asks about and is granted relations
|
|
89
110
|
on, like a record, and `grant(note, 'readers', setOf(bob, 'managers'))`
|
|
@@ -100,7 +121,10 @@ Each entry names the version it came in.
|
|
|
100
121
|
audit trail, never a login, a password, a session token or a one-time token;
|
|
101
122
|
and [`@nxgt/janus-kit`](https://www.npmjs.com/package/@nxgt/janus-kit), all
|
|
102
123
|
of it wired in one call.
|
|
103
|
-
|
|
124
|
+
- **A NUL character or a lone surrogate never reaches a store** — refused in
|
|
125
|
+
fields with `USER_INVALID` on every adapter, rather than `STORE_FAILED` on
|
|
126
|
+
PostgreSQL alone; a login holding one is nobody's. The conformance suite
|
|
127
|
+
holds every adapter to round-tripping every other character. — v0.2.1
|
|
104
128
|
- **The model's keys read `related` and `permits`** — Keto's OPL words: an
|
|
105
129
|
object type declares `related: { members: ['staff', 'team#members'] }` and
|
|
106
130
|
`permits: { view: ['members'] }`, and relation names are plural by convention. Breaking:
|
|
@@ -114,11 +138,6 @@ Each entry names the version it came in.
|
|
|
114
138
|
- **The conformance suite accepts a store with its own expiry** — a store
|
|
115
139
|
that drops a lapsed session at once, as a Redis TTL does, passes
|
|
116
140
|
`sessions.deleteUser`; one that still holds it must count it. — v0.2.0
|
|
117
|
-
- **A NUL character or a lone surrogate never reaches a store** — refused in
|
|
118
|
-
fields with `USER_INVALID` on every adapter, rather than `STORE_FAILED` on
|
|
119
|
-
PostgreSQL alone; a login holding one is nobody's. The conformance suite
|
|
120
|
-
holds every adapter to round-tripping every other character. — v0.2.1
|
|
121
|
-
|
|
122
141
|
- **A Hono integration** — [`@nxgt/janus-hono`](https://www.npmjs.com/package/@nxgt/janus-hono):
|
|
123
142
|
the session middleware, the cookie, a route guarded by a permission,
|
|
124
143
|
`bindJanus()` to bind the instances once, and every error as its status.
|
|
@@ -127,35 +146,3 @@ Each entry names the version it came in.
|
|
|
127
146
|
in a relation, subject types in `fromField`, relations, permissions and
|
|
128
147
|
arrows in a rule and in `when`; a wrong name's error lists the names it
|
|
129
148
|
could have been. — v0.1.2
|
|
130
|
-
|
|
131
|
-
- **The model decides what a stored tuple grants** — `can()` and `list()` follow
|
|
132
|
-
only the holders a relation admits, as `grant()` writes only those: a tuple
|
|
133
|
-
stored past `grant()`, by an older model or by hand, grants nothing. — v0.1
|
|
134
|
-
- **Guides and troubleshooting pages** — a `docs/` folder shipped in the
|
|
135
|
-
package: detailed guides with examples, and the errors you can meet, each
|
|
136
|
-
with its cause and fix. — v0.1
|
|
137
|
-
- **Permissions at `@nxgt/janus/permissions`** — and `janus({ relations })`,
|
|
138
|
-
so deleting a user also deletes every tuple naming them. — v0.1
|
|
139
|
-
- **`list()`** — the ids of every object a subject holds a permission on, as a
|
|
140
|
-
cursor page, `fromField` relations included through their `lookup`. — v0.1
|
|
141
|
-
- **`can()`, `grant()` and `revoke()`** — a permission check that answers
|
|
142
|
-
`true` or `false` and throws on an outage, and tuple writes refused at
|
|
143
|
-
compile time when the model does not admit them. — v0.1
|
|
144
|
-
- **Typed subjects and the `RelationStore` port** — `{ type, id }` subjects,
|
|
145
|
-
the tuple notation, `createMemoryRelations()`, and
|
|
146
|
-
`describeRelationStores` for adapter authors. — v0.1
|
|
147
|
-
- **A permission model typed from itself** — `defineModel` with subject sets,
|
|
148
|
-
arrows, `fromField` relations read from your data, and `when` conditions
|
|
149
|
-
written in TypeScript. — v0.1
|
|
150
|
-
- **Delete a user, and everything of theirs** — `delete(user)` removes the
|
|
151
|
-
user with every session and one-time token they had, idempotently. — v0.1
|
|
152
|
-
- **Rehash a stale password on sign-in** — moving hashers, or raising a cost,
|
|
153
|
-
reaches every active user with no migration to run. — v0.1
|
|
154
|
-
- **`janus()`** — sign-up, sign-in, sessions, e-mail verification and password
|
|
155
|
-
reset, with several user types in one instance, typed from your schemas.
|
|
156
|
-
— v0.1
|
|
157
|
-
- **The conformance suite** — `@nxgt/janus/conformance`, the suite an adapter
|
|
158
|
-
runs, outages included. — v0.1
|
|
159
|
-
- **The identity stores' port and its in-memory reference** — `JanusStores` and
|
|
160
|
-
`createMemoryStores()`, for your tests and as the model for an adapter.
|
|
161
|
-
— v0.1
|
package/docs/troubleshooting.md
CHANGED
|
@@ -23,7 +23,7 @@ How the messages are shaped:
|
|
|
23
23
|
- [`error instanceof StoreFailure` is `false` for an outage](#error-instanceof-storefailure-is-false-for-an-outage)
|
|
24
24
|
|
|
25
25
|
**Configuring `janus()`**
|
|
26
|
-
- [`janus: pass either user … or users …, and exactly one of them`](#janus-pass-either-user-one-
|
|
26
|
+
- [`janus: pass either user … or users …, and exactly one of them`](#janus-pass-either-user-one-user-type-or-users-several-user-types-and-exactly-one-of-them)
|
|
27
27
|
- [`janus: user must be a Standard Schema …`](#janus-user-must-be-a-standard-schema--a-zod-4-valibot-or-arktype-schema)
|
|
28
28
|
- [`janus: a user type signs in with a password and no hasher is wired …`](#janus-a-user-type-signs-in-with-a-password-and-no-hasher-is-wired--pass-hasher-scrypthasher-or-bunhasher-on-bun-there-is-no-silent-fallback)
|
|
29
29
|
- [`bunHasher: Bun.password is not available …`](#bunhasher-bunpassword-is-not-available--this-runtime-is-not-bun-wire-scrypthasher-instead)
|
|
@@ -36,6 +36,9 @@ How the messages are shaped:
|
|
|
36
36
|
- [`janus: the user type "<name>" must be a camelCase name …`](#janus-the-user-type-name-must-be-a-camelcase-name--letters-and-digits-starting-with-a-letter)
|
|
37
37
|
- [`janus: session.lifespan: "<value>" is not a duration …`](#janus-sessionlifespan-value-is-not-a-duration-write-a-number-followed-by-ms-s-m-h-or-d--for-example-15m-or-720h)
|
|
38
38
|
- [`janus: cookie.sameSite "none" requires cookie.secure …`](#janus-cookiesamesite-none-requires-cookiesecure--browsers-refuse-the-cookie-otherwise)
|
|
39
|
+
- [`janus: secondFactor.issuer must name your application …`](#janus-secondfactorissuer-must-name-your-application--the-authenticator-app-shows-it-beside-the-account)
|
|
40
|
+
- [`janus: secondFactor.keys: the key "<id>" is not 32 bytes in base64 …`](#janus-secondfactorkeys-the-key-id-is-not-32-bytes-in-base64--make-one-with-openssl-rand--base64-32)
|
|
41
|
+
- [`"hasSecondFactor" is a field janus sets itself; rename it`](#hassecondfactor-is-a-field-janus-sets-itself-rename-it)
|
|
39
42
|
- [Other `janus:` wiring messages](#other-janus-wiring-messages)
|
|
40
43
|
|
|
41
44
|
**Users, sessions and tokens**
|
|
@@ -58,6 +61,19 @@ How the messages are shaped:
|
|
|
58
61
|
- [`<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)
|
|
59
62
|
- [`authenticate()` answers `null` although a valid cookie was sent](#authenticate-answers-null-although-a-valid-cookie-was-sent)
|
|
60
63
|
|
|
64
|
+
**Second factor**
|
|
65
|
+
- [`TS2339: Property 'token' does not exist on type 'SignInResult<…>'.`](#ts2339-property-token-does-not-exist-on-type-signinresult)
|
|
66
|
+
- [`CODE_INVALID` — `<call>: the code does not match, or was already used`](#code_invalid--call-the-code-does-not-match-or-was-already-used)
|
|
67
|
+
- [The code the authenticator app shows is refused with `CODE_INVALID`](#the-code-the-authenticator-app-shows-is-refused-with-code_invalid)
|
|
68
|
+
- [`SECOND_FACTOR_NOT_ENROLLED` — `secondFactor.activate: the user has no second factor waiting …`](#second_factor_not_enrolled--secondfactoractivate-the-user-has-no-second-factor-waiting--call-enroll-first)
|
|
69
|
+
- [`SECOND_FACTOR_ACTIVE` — `secondFactor.enroll: the user's second factor is active …`](#second_factor_active--secondfactorenroll-the-users-second-factor-is-active--disable-it-first)
|
|
70
|
+
- [`<call>: …, and janus() was given no secondFactor …`](#call--and-janus-was-given-no-secondfactor--pass-secondfactor--issuer-keys-)
|
|
71
|
+
- [`<call>: the secret is sealed with the key "<id>", which secondFactor.keys no longer holds …`](#call-the-secret-is-sealed-with-the-key-id-which-secondfactorkeys-no-longer-holds--keep-a-key-until-no-secret-is-sealed-with-it)
|
|
72
|
+
- [`<call>: the secret does not open with the key "<id>" — was that key changed under the same id, or the secret copied from another user?`](#call-the-secret-does-not-open-with-the-key-id--was-that-key-changed-under-the-same-id-or-the-secret-copied-from-another-user)
|
|
73
|
+
- [`<call>: the stored secret is not a sealed one`](#call-the-stored-secret-is-not-a-sealed-one)
|
|
74
|
+
- [`<call>: the <type> type does not sign in with a password, so it has no second factor`](#call-the-type-type-does-not-sign-in-with-a-password-so-it-has-no-second-factor)
|
|
75
|
+
- `TOKEN_*`, `USER_INACTIVE` and `VERSION_CONFLICT` from `secondFactor.confirm`: in their entries above.
|
|
76
|
+
|
|
61
77
|
**Permissions**
|
|
62
78
|
- [`PERMISSION_DEPTH` — `can: checking <type>#<permission> crossed more than <n> relations without an answer`](#permission_depth--can-checking-typepermission-crossed-more-than-n-relations-without-an-answer)
|
|
63
79
|
- [`permissions: this model was not made by defineModel() …`](#permissions-this-model-was-not-made-by-definemodel--pass-what-definemodel-answered)
|
|
@@ -73,6 +89,7 @@ How the messages are shaped:
|
|
|
73
89
|
|
|
74
90
|
**Subjects**
|
|
75
91
|
- [`parseTuple: "<text>" is not a relation tuple; expected type:id#relation@subject`](#parsetuple-text-is-not-a-relation-tuple-expected-typeidrelationsubject)
|
|
92
|
+
- [`setOf: pass a user or { type, id }, then a relation`](#setof-pass-a-user-or--type-id--then-a-relation)
|
|
76
93
|
|
|
77
94
|
**Conformance (adapter authors)**
|
|
78
95
|
- [`describeJanusStores: no test runner on globalThis …`](#describejanusstores-no-test-runner-on-globalthis--pass-runner--describe-it--under-bun-test-import-them-from-buntest)
|
|
@@ -208,6 +225,10 @@ Also: `janus: store.<slot> is missing`, `janus: store must be an object with use
|
|
|
208
225
|
|
|
209
226
|
**When:** `janus({...})`, from JavaScript or with a store typed loosely. TypeScript refuses a partial store at compile time and names the method.
|
|
210
227
|
**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.
|
|
228
|
+
An adapter written against `@nxgt/janus` 0.3 reports
|
|
229
|
+
`store.tokens has no method countAttempt` until it implements the method 0.4
|
|
230
|
+
added.
|
|
231
|
+
|
|
211
232
|
**Fix:** pass the three stores, whole:
|
|
212
233
|
|
|
213
234
|
```ts
|
|
@@ -216,6 +237,18 @@ import { createMemoryStores, janus } from '@nxgt/janus';
|
|
|
216
237
|
janus({ ..., store: createMemoryStores() });
|
|
217
238
|
```
|
|
218
239
|
|
|
240
|
+
For `countAttempt`, upgrade the published adapter to the release that
|
|
241
|
+
implements it — `@nxgt/janus-drizzle` 0.2, `@nxgt/janus-mongo` 0.3,
|
|
242
|
+
`@nxgt/janus-redis` 0.2:
|
|
243
|
+
|
|
244
|
+
```bash
|
|
245
|
+
bun add @nxgt/janus@^0.4 @nxgt/janus-drizzle@^0.2 # or @nxgt/janus-mongo@^0.3, @nxgt/janus-redis@^0.2
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Your own adapter implements it as
|
|
249
|
+
[`TokenStore.countAttempt`](guide/adapters.md#tokenstorecountattempt) sets
|
|
250
|
+
out, then runs the conformance suite.
|
|
251
|
+
|
|
219
252
|
### `janus: relations must be a relation store — relations.deleteEntity is missing`
|
|
220
253
|
|
|
221
254
|
**When:** `janus({ ..., relations })`.
|
|
@@ -258,7 +291,7 @@ Also `janus: "<name>" cannot name a user type — janus() answers a method of th
|
|
|
258
291
|
|
|
259
292
|
### `janus: session.lifespan: "<value>" is not a duration; write a number followed by ms, s, m, h or d — for example "15m" or "720h"`
|
|
260
293
|
|
|
261
|
-
The same for `session.renewAfter`, `tokens.verifyEmail` and `
|
|
294
|
+
The same for `session.renewAfter`, `tokens.verifyEmail`, `tokens.resetPassword` and `secondFactor.challenge`. Also `<option>: a duration must be above zero` and `<option>: a duration in milliseconds must be a finite number above zero`.
|
|
262
295
|
|
|
263
296
|
**When:** `janus({...})`.
|
|
264
297
|
**Why:** a duration is a number of milliseconds, or a number followed by one unit. `'30 m'` compiles — TypeScript's `${number}` accepts the space — and is refused here.
|
|
@@ -276,6 +309,48 @@ Also `janus: cookie.name must be a cookie-name token — letters, digits and !#$
|
|
|
276
309
|
**Why:** browsers drop a `SameSite=None` cookie that is not `Secure`, so every sign-in would silently fail to stick.
|
|
277
310
|
**Fix:** keep `secure: true` with `sameSite: 'none'`, or use the default `sameSite: 'lax'` when the site and the API share a site.
|
|
278
311
|
|
|
312
|
+
### `janus: secondFactor.issuer must name your application — the authenticator app shows it beside the account`
|
|
313
|
+
|
|
314
|
+
**When:** `janus({ ..., secondFactor })`, when `issuer` is missing, is not a string, or is blank.
|
|
315
|
+
**Why:** the issuer is the label the authenticator app shows beside the account. Without it, a user with several accounts in the app cannot tell which code is yours.
|
|
316
|
+
**Fix:**
|
|
317
|
+
|
|
318
|
+
```ts
|
|
319
|
+
janus({ ..., secondFactor: { issuer: 'Acme', keys } });
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
### `janus: secondFactor.keys: the key "<id>" is not 32 bytes in base64 — make one with openssl rand -base64 32`
|
|
323
|
+
|
|
324
|
+
Also:
|
|
325
|
+
|
|
326
|
+
- `janus: secondFactor.keys: expected at least one key — [{ id, key }], the first seals`
|
|
327
|
+
- `janus: secondFactor.keys: every key needs an id of letters, digits, _ and -, at most 64 of them`
|
|
328
|
+
- `janus: secondFactor.keys: two keys have the id "<id>"`
|
|
329
|
+
|
|
330
|
+
**When:** `janus({ ..., secondFactor })`.
|
|
331
|
+
**Why:** every TOTP secret is sealed with AES-256-GCM before a store sees it, and that takes a 32-byte key: 43 characters of base64 or base64url, with or without the trailing `=`. A hex key (64 characters), a passphrase, or an environment variable that is not set is refused. The id is written into every secret the key seals, so it is short, plain, and names one key only.
|
|
332
|
+
**Fix:** make each key once and keep it with your other secrets:
|
|
333
|
+
|
|
334
|
+
```sh
|
|
335
|
+
openssl rand -base64 32
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
```ts
|
|
339
|
+
janus({
|
|
340
|
+
...,
|
|
341
|
+
secondFactor: {
|
|
342
|
+
issuer: 'Acme',
|
|
343
|
+
keys: [{ id: 'k2026a', key: process.env.TOTP_KEY_K2026A ?? '' }], // the first key seals
|
|
344
|
+
},
|
|
345
|
+
});
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
### `"hasSecondFactor" is a field janus sets itself; rename it`
|
|
349
|
+
|
|
350
|
+
**When:** `tsc`, on the `janus({...})` call, when your schema declares a `hasSecondFactor` field. This version added it to the fields janus sets on every user.
|
|
351
|
+
**Why:** `user.hasSecondFactor` is janus's own answer: whether the user's second factor is active. A field of yours with that name would be shadowed. From JavaScript, a validated input holding it is refused with `USER_INVALID` and the issue `set by janus, not by a request`.
|
|
352
|
+
**Fix:** rename the field in your schema, and read `user.hasSecondFactor` for janus's answer.
|
|
353
|
+
|
|
279
354
|
### Other `janus:` wiring messages
|
|
280
355
|
|
|
281
356
|
| Message | Fix |
|
|
@@ -349,6 +424,23 @@ const user = await auth.get(id);
|
|
|
349
424
|
await auth.update(user, { name }, { ifVersion: user.version });
|
|
350
425
|
```
|
|
351
426
|
|
|
427
|
+
**From `secondFactor.confirm`**, the message is the store's:
|
|
428
|
+
`updateUser: expected version <n>, found <m>`.
|
|
429
|
+
|
|
430
|
+
**When:** two `confirm` calls for one user run at once with the same code — a
|
|
431
|
+
double-submitted form, two tabs, a client that retries before the first answer.
|
|
432
|
+
**Why:** of two codes accepted at once, the first write wins and opens a
|
|
433
|
+
session. The second finds the user's version moved and writes nothing: no
|
|
434
|
+
session. Its challenge is not spent, and has lost one attempt.
|
|
435
|
+
**Fix:** submit the code form once. The session already exists, from the call
|
|
436
|
+
that won; if the visitor still needs one, the next code works on the same
|
|
437
|
+
challenge — the same code will not, it was used.
|
|
438
|
+
|
|
439
|
+
```ts
|
|
440
|
+
// in the browser: one submit per code
|
|
441
|
+
form.addEventListener('submit', () => form.querySelector('button')?.setAttribute('disabled', ''));
|
|
442
|
+
```
|
|
443
|
+
|
|
352
444
|
### `USER_INVALID` — `<call>: the fields do not match the <type> schema (<n> issues, at <paths>)`
|
|
353
445
|
|
|
354
446
|
`UserInvalidError`, carrying `issues` — each a `path` and a `message`.
|
|
@@ -400,9 +492,9 @@ janus({ ..., hasher: scryptHasher(), verifiers: [bcryptVerifier] }); // a Passwo
|
|
|
400
492
|
|
|
401
493
|
### `USER_INACTIVE` — `<call>: the user is inactive`
|
|
402
494
|
|
|
403
|
-
**When:** `signIn`, with the **right** password, for a user set inactive.
|
|
404
|
-
**Why:** an inactive user keeps their record and password, and every sign-in is refused. It is checked after the password, so only somebody who knows the password learns the user is inactive.
|
|
405
|
-
**Fix:** answer 403, or reactivate: `await auth.setActive(user, true)`.
|
|
495
|
+
**When:** `signIn`, with the **right** password, for a user set inactive. Also `secondFactor.confirm`, for a user set inactive after `signIn` asked for a code.
|
|
496
|
+
**Why:** an inactive user keeps their record and password, and every sign-in is refused. It is checked after the password, so only somebody who knows the password learns the user is inactive. On `secondFactor.confirm`, the challenge is spent: reactivating the user does not revive it.
|
|
497
|
+
**Fix:** answer 403, or reactivate, then sign in again: `await auth.setActive(user, true)`.
|
|
406
498
|
|
|
407
499
|
### `TOKEN_UNKNOWN`, `TOKEN_SPENT`, `TOKEN_EXPIRED`
|
|
408
500
|
|
|
@@ -416,6 +508,24 @@ janus({ ..., hasher: scryptHasher(), verifiers: [bcryptVerifier] }); // a Passwo
|
|
|
416
508
|
janus({ ..., tokens: { verifyEmail: '72h', resetPassword: '2h' } });
|
|
417
509
|
```
|
|
418
510
|
|
|
511
|
+
**On `secondFactor.confirm`**, the messages name the challenge `signIn`
|
|
512
|
+
answered: `secondFactor.confirm: no such challenge`,
|
|
513
|
+
`secondFactor.confirm: the challenge was already used`,
|
|
514
|
+
`secondFactor.confirm: the challenge has expired`.
|
|
515
|
+
|
|
516
|
+
**When:** `secondFactor.confirm(challenge, code)`.
|
|
517
|
+
**Why:** a challenge lives five minutes and takes five codes. It is spent by
|
|
518
|
+
the code that opens the session, by the fifth wrong code, and by a refusal
|
|
519
|
+
that ends it (`USER_INACTIVE`, `SECOND_FACTOR_NOT_ENROLLED`). `TOKEN_UNKNOWN`
|
|
520
|
+
also covers a challenge whose user was deleted, and a challenge passed where a
|
|
521
|
+
code was expected — the two arguments swapped.
|
|
522
|
+
**Fix:** answer 400 and send the visitor back to sign in, which asks for a new
|
|
523
|
+
code. To give slower visitors more time:
|
|
524
|
+
|
|
525
|
+
```ts
|
|
526
|
+
janus({ ..., secondFactor: { issuer: 'Acme', keys, challenge: '10m' } });
|
|
527
|
+
```
|
|
528
|
+
|
|
419
529
|
### `TOKEN_STALE` — `<call>: the token was sent to an e-mail the user no longer has`
|
|
420
530
|
|
|
421
531
|
**When:** `verifyEmail.confirm` or `resetPassword.confirm`, after the user changed their e-mail.
|
|
@@ -470,6 +580,167 @@ const page = await access.list(user, 'view', 'record', {
|
|
|
470
580
|
|
|
471
581
|
---
|
|
472
582
|
|
|
583
|
+
## Second factor
|
|
584
|
+
|
|
585
|
+
Every entry here needs `janus({ ..., secondFactor })`. The messages start with
|
|
586
|
+
`secondFactor.enroll`, `secondFactor.activate`, `secondFactor.confirm` or
|
|
587
|
+
`signIn` — prefixed by the type with several user types:
|
|
588
|
+
`staff.secondFactor.confirm: …`. `secondFactor.confirm` also rejects with
|
|
589
|
+
[`TOKEN_UNKNOWN`, `TOKEN_SPENT`, `TOKEN_EXPIRED`](#token_unknown-token_spent-token_expired),
|
|
590
|
+
[`USER_INACTIVE`](#user_inactive--call-the-user-is-inactive) and
|
|
591
|
+
[`VERSION_CONFLICT`](#version_conflict--call-expected-version-n-found-m);
|
|
592
|
+
each of those entries has a paragraph for it.
|
|
593
|
+
|
|
594
|
+
### `TS2339: Property 'token' does not exist on type 'SignInResult<…>'.`
|
|
595
|
+
|
|
596
|
+
The same for `session` and `user`.
|
|
597
|
+
|
|
598
|
+
**When:** `tsc`, wherever `signIn`'s answer is read, once `janus()` is given a `secondFactor`.
|
|
599
|
+
**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.
|
|
600
|
+
**Fix:** switch on `status`:
|
|
601
|
+
|
|
602
|
+
```ts
|
|
603
|
+
const result = await auth.signIn({ email, password });
|
|
604
|
+
if (result.status === 'secondFactor') {
|
|
605
|
+
// Keep the challenge for the next request — never in a URL — and ask for a code.
|
|
606
|
+
return Response.json({ challenge: result.challenge, expiresAt: result.expiresAt });
|
|
607
|
+
}
|
|
608
|
+
return Response.json({ token: result.token });
|
|
609
|
+
|
|
610
|
+
// the next request, with the code the app shows
|
|
611
|
+
const signedIn = await auth.secondFactor.confirm(challenge, code); // { status: 'signedIn', token, … }
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
### `CODE_INVALID` — `<call>: the code does not match, or was already used`
|
|
615
|
+
|
|
616
|
+
`TokenError`, carrying `attemptsLeft` on `secondFactor.confirm`.
|
|
617
|
+
|
|
618
|
+
**When:** `secondFactor.activate(user, code)` or `secondFactor.confirm(challenge, code)`.
|
|
619
|
+
**Why:** the code is wrong, or it was accepted before: a code is accepted once. On `confirm`, every call costs one of the challenge's five attempts, counted before anything is checked. `attemptsLeft` is what remains; at `0`, the challenge is spent and the next `confirm` is `TOKEN_SPENT`. On `activate` there is no challenge, no `attemptsLeft`, and nothing is spent: the user tries the next code.
|
|
620
|
+
**Fix:** answer 401 with `attemptsLeft`, and send the visitor back to sign in once it is `0`:
|
|
621
|
+
|
|
622
|
+
```ts
|
|
623
|
+
import { TokenError } from '@nxgt/janus';
|
|
624
|
+
|
|
625
|
+
try {
|
|
626
|
+
return await auth.secondFactor.confirm(challenge, code);
|
|
627
|
+
} catch (error) {
|
|
628
|
+
if (error instanceof TokenError && error.code === 'CODE_INVALID') {
|
|
629
|
+
return Response.json({ code: error.code, attemptsLeft: error.attemptsLeft }, { status: 401 });
|
|
630
|
+
}
|
|
631
|
+
throw error;
|
|
632
|
+
}
|
|
633
|
+
```
|
|
634
|
+
|
|
635
|
+
If the code the user typed is the one their app shows, see the next entry.
|
|
636
|
+
|
|
637
|
+
### The code the authenticator app shows is refused with `CODE_INVALID`
|
|
638
|
+
|
|
639
|
+
**When:** `activate` or `confirm`, with the code on screen, typed correctly.
|
|
640
|
+
**Why**, in the order to check:
|
|
641
|
+
|
|
642
|
+
1. **A clock is off.** A code is accepted in its own 30-second step and one step either side. A phone or a server whose clock is more than about 30 seconds away from the real time produces codes outside those three steps. How many steps are accepted is not configurable.
|
|
643
|
+
2. **The code was already used** — and so were the codes before it. Once a code is accepted, that code and every earlier one are refused: the user who activates and then signs in within the same 30 seconds, or who signs in twice in a row, must wait for the next code.
|
|
644
|
+
3. **The app holds an older secret.** `enroll` called again before `activate` replaces the secret: an entry scanned from the first `enroll` shows codes for a secret nobody holds any more.
|
|
645
|
+
|
|
646
|
+
**Fix:** for 1, keep the server's clock synchronised and ask the user to set their phone's time automatically. For 2, wait for the next code. For 3, delete the entry from the app and scan the `uri` of the last `enroll`.
|
|
647
|
+
|
|
648
|
+
```sh
|
|
649
|
+
timedatectl show -p NTPSynchronized # NTPSynchronized=yes on the server
|
|
650
|
+
```
|
|
651
|
+
|
|
652
|
+
### `SECOND_FACTOR_NOT_ENROLLED` — `secondFactor.activate: the user has no second factor waiting — call enroll first`
|
|
653
|
+
|
|
654
|
+
`SecondFactorError`. Also `secondFactor.confirm: the user no longer has a second factor — sign in again`.
|
|
655
|
+
|
|
656
|
+
**When:** `activate` before `enroll`, or after `disable`. On `confirm`: the factor was disabled after `signIn` asked for a code.
|
|
657
|
+
**Why:** `activate` checks a code against the secret `enroll` wrote, and there is none. On `confirm`, the factor the challenge asked for is gone, so the challenge is spent.
|
|
658
|
+
**Fix:** `enroll`, show the `uri` as a QR code, then `activate` with a code from the app. After `confirm`'s refusal, sign in again: `signIn` answers a session directly for a user without a factor.
|
|
659
|
+
|
|
660
|
+
```ts
|
|
661
|
+
const { secret, uri } = await auth.secondFactor.enroll(user);
|
|
662
|
+
// …the user scans uri, or types secret…
|
|
663
|
+
await auth.secondFactor.activate(user, code);
|
|
664
|
+
```
|
|
665
|
+
|
|
666
|
+
### `SECOND_FACTOR_ACTIVE` — `secondFactor.enroll: the user's second factor is active — disable it first`
|
|
667
|
+
|
|
668
|
+
`SecondFactorError`. Also `secondFactor.activate: the user's second factor is already active`.
|
|
669
|
+
|
|
670
|
+
**When:** `enroll` or `activate` for a user whose factor is already active — often a form submitted twice, or a "set up again" button.
|
|
671
|
+
**Why:** enrolling again would replace the secret and quietly switch the factor off until the new one is activated. It is refused so that only `disable` switches it off.
|
|
672
|
+
**Fix:** check `user.hasSecondFactor` first. To move to a new phone, disable, then enroll:
|
|
673
|
+
|
|
674
|
+
```ts
|
|
675
|
+
const cleared = await auth.secondFactor.disable(user);
|
|
676
|
+
const { uri } = await auth.secondFactor.enroll(cleared);
|
|
677
|
+
```
|
|
678
|
+
|
|
679
|
+
### `<call>: …, and janus() was given no secondFactor — pass secondFactor: { issuer, keys }`
|
|
680
|
+
|
|
681
|
+
Most often: `signIn: the user's second factor is active, and janus() was given no secondFactor — pass secondFactor: { issuer, keys }`. A `TypeError`.
|
|
682
|
+
|
|
683
|
+
**When:** `signIn` for a user whose factor is active, on a `janus()` built without `secondFactor` — a second process over the same users: a worker, a script, an admin service, an older deployment. From JavaScript, `enroll`, `activate` and `confirm` on such an instance too.
|
|
684
|
+
**Why:** the password alone never opens a session for a user with an active factor, and an instance without keys cannot check a code. It refuses rather than sign the user in on the password alone.
|
|
685
|
+
**Fix:** give every `janus()` over the same users the same `secondFactor`, from one module:
|
|
686
|
+
|
|
687
|
+
```ts
|
|
688
|
+
// auth-config.ts, imported by every process
|
|
689
|
+
export const secondFactor = { issuer: 'Acme', keys } as const;
|
|
690
|
+
|
|
691
|
+
janus({ ..., secondFactor });
|
|
692
|
+
```
|
|
693
|
+
|
|
694
|
+
### `<call>: the secret is sealed with the key "<id>", which secondFactor.keys no longer holds — keep a key until no secret is sealed with it`
|
|
695
|
+
|
|
696
|
+
A `TypeError`.
|
|
697
|
+
|
|
698
|
+
**When:** `activate` or `confirm`, for a user whose secret was sealed with a key since removed from `keys` — or with a key another deployment has and this one does not.
|
|
699
|
+
**Why:** each sealed secret names its key. The first key seals, every key opens, and a secret is sealed again under the first key only the next time a code of that user is accepted. A user who has not signed in since the rotation still holds the old seal.
|
|
700
|
+
**Fix:** put the old key back, after the new one:
|
|
701
|
+
|
|
702
|
+
```ts
|
|
703
|
+
secondFactor: {
|
|
704
|
+
issuer: 'Acme',
|
|
705
|
+
keys: [
|
|
706
|
+
{ id: 'k2026b', key: process.env.TOTP_KEY_K2026B ?? '' }, // seals
|
|
707
|
+
{ id: 'k2026a', key: process.env.TOTP_KEY_K2026A ?? '' }, // still opens
|
|
708
|
+
],
|
|
709
|
+
},
|
|
710
|
+
```
|
|
711
|
+
|
|
712
|
+
A key can go once no stored second-factor secret starts with `v1.<its id>.`.
|
|
713
|
+
|
|
714
|
+
### `<call>: the secret does not open with the key "<id>" — was that key changed under the same id, or the secret copied from another user?`
|
|
715
|
+
|
|
716
|
+
A `TypeError`.
|
|
717
|
+
|
|
718
|
+
**When:** `activate` or `confirm`.
|
|
719
|
+
**Why:** the key held under that id is not the one that sealed the secret: its value was changed and its id kept, or two environments sharing one database hold different keys under one id. It is also the message for a sealed secret copied onto another user — a seal is bound to the user's id — for example a user record duplicated by hand.
|
|
720
|
+
**Fix:** restore the original key under that id. A new key always takes a new id. For a copied record, disable the factor and have the user enroll again: `await auth.secondFactor.disable(user)`.
|
|
721
|
+
|
|
722
|
+
### `<call>: the stored secret is not a sealed one`
|
|
723
|
+
|
|
724
|
+
A `TypeError`.
|
|
725
|
+
|
|
726
|
+
**When:** `activate` or `confirm`.
|
|
727
|
+
**Why:** the stored secret is not `v1.<key id>.<iv>.<sealed>`: it was written into the store directly — a plain base32 secret imported from another system — or cut short.
|
|
728
|
+
**Fix:** never write the second factor into the store yourself. Disable it and have the user enroll again:
|
|
729
|
+
|
|
730
|
+
```ts
|
|
731
|
+
await auth.secondFactor.disable(user);
|
|
732
|
+
```
|
|
733
|
+
|
|
734
|
+
### `<call>: the <type> type does not sign in with a password, so it has no second factor`
|
|
735
|
+
|
|
736
|
+
A `TypeError`.
|
|
737
|
+
|
|
738
|
+
**When:** `secondFactor.enroll`, from JavaScript, on a user type without `password`. In TypeScript, `secondFactor` is absent from such a type.
|
|
739
|
+
**Why:** a second factor is asked for after a password. A type that signs in otherwise has nothing to ask it after, and no login to show in the app.
|
|
740
|
+
**Fix:** enroll only users of a type with `password: { login }`.
|
|
741
|
+
|
|
742
|
+
---
|
|
743
|
+
|
|
473
744
|
## Permissions
|
|
474
745
|
|
|
475
746
|
### `PERMISSION_DEPTH` — `can: checking <type>#<permission> crossed more than <n> relations without an answer`
|
package/package.json
CHANGED
|
File without changes
|
|
File without changes
|