@nxgt/janus 0.4.0 → 0.6.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 +159 -20
- package/dist/auth/config.d.ts +28 -1
- package/dist/auth/config.d.ts.map +1 -1
- package/dist/auth/context.d.ts +6 -0
- 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 +92 -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 +14 -0
- package/dist/auth/second-factor/challenge.d.ts.map +1 -0
- package/dist/auth/second-factor/factor.d.ts +27 -0
- package/dist/auth/second-factor/factor.d.ts.map +1 -0
- package/dist/auth/second-factor/flows.d.ts +23 -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/sign-in-code.d.ts +17 -0
- package/dist/auth/sign-in-code.d.ts.map +1 -0
- package/dist/auth/totp.d.ts +36 -0
- package/dist/auth/totp.d.ts.map +1 -0
- package/dist/auth/types.d.ts +140 -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 +478 -51
- package/dist/index.js.map +15 -7
- package/dist/permissions/index.js +3 -3
- package/docs/README.md +2 -0
- package/docs/guide/adapters.md +12 -7
- package/docs/guide/email-flows.md +4 -0
- package/docs/guide/errors.md +19 -7
- package/docs/guide/second-factor.md +571 -0
- package/docs/guide/sessions.md +18 -0
- package/docs/guide/sign-in-code.md +471 -0
- package/docs/guide/users.md +10 -3
- package/docs/guide/vocabulary.md +9 -6
- package/docs/roadmap.md +39 -54
- package/docs/troubleshooting.md +414 -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/roadmap.md
CHANGED
|
@@ -5,16 +5,20 @@ dates here, and the version something shipped in is the only number.
|
|
|
5
5
|
|
|
6
6
|
## Now
|
|
7
7
|
|
|
8
|
-
|
|
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; and
|
|
11
|
-
TOTP, the one-time codes of an authenticator app, as a second factor, its
|
|
12
|
-
secret sealed with a key your application holds. `signIn` will answer
|
|
13
|
-
`{ status: 'signedIn' }` or `{ status: 'secondFactor', challenge }`. The
|
|
14
|
-
store port that holds them shipped in v0.4.0, below.
|
|
8
|
+
Nothing between releases.
|
|
15
9
|
|
|
16
10
|
## Next
|
|
17
11
|
|
|
12
|
+
- **Confirm an action with an e-mailed code (step-up)** — a signed-in user
|
|
13
|
+
proves they still read their inbox before something a stolen session should
|
|
14
|
+
not do alone: changing the e-mail, disabling the second factor, deleting
|
|
15
|
+
the account. The same six digits, challenge and five attempts as a sign-in
|
|
16
|
+
code, bound to the session that asked rather than opening one — which
|
|
17
|
+
takes a token kind of its own, so a sign-in code can never confirm an
|
|
18
|
+
action nor an action's code sign anyone in.
|
|
19
|
+
- **Recovery codes** — single-use codes for the TOTP second factor, so a
|
|
20
|
+
user who loses their authenticator app can still sign in, without an
|
|
21
|
+
operator resetting the account.
|
|
18
22
|
- **Sending the e-mails** — in a package of its own, `@nxgt/janus-mail`, built
|
|
19
23
|
on a general mail toolkit shared with applications that are not about
|
|
20
24
|
sign-in: a `Mailer` port you plug your transport into (SMTP, Resend, SES…) —
|
|
@@ -44,8 +48,10 @@ dates here, and the version something shipped in is the only number.
|
|
|
44
48
|
- **More official adapters** — the ports are cut where atomicity is not
|
|
45
49
|
required, so users, sessions and permission tuples can each live in the
|
|
46
50
|
database that suits them. MongoDB is the first adapter
|
|
47
|
-
([`@nxgt/janus-mongo`](https://www.npmjs.com/package/@nxgt/janus-mongo))
|
|
48
|
-
PostgreSQL
|
|
51
|
+
([`@nxgt/janus-mongo`](https://www.npmjs.com/package/@nxgt/janus-mongo)),
|
|
52
|
+
PostgreSQL and Redis followed
|
|
53
|
+
([`@nxgt/janus-drizzle`](https://www.npmjs.com/package/@nxgt/janus-drizzle),
|
|
54
|
+
[`@nxgt/janus-redis`](https://www.npmjs.com/package/@nxgt/janus-redis)).
|
|
49
55
|
|
|
50
56
|
## Not planned
|
|
51
57
|
|
|
@@ -84,14 +90,31 @@ dates here, and the version something shipped in is the only number.
|
|
|
84
90
|
|
|
85
91
|
## Shipped
|
|
86
92
|
|
|
87
|
-
|
|
93
|
+
The last ten, newest first, each with the version it came in. Everything
|
|
94
|
+
before is in the [CHANGELOG](../CHANGELOG.md).
|
|
88
95
|
|
|
96
|
+
- **Sign in with a code sent by e-mail, v0.6.0** —
|
|
97
|
+
`auth.<type>.signInCode.request(email)` answers a six-digit code to send
|
|
98
|
+
and a challenge to keep with the visitor, or `null` for nobody — never
|
|
99
|
+
saying which; `signInCode.confirm(challenge, code)` marks the e-mail
|
|
100
|
+
verified and opens the session. On every user type with an e-mail, one
|
|
101
|
+
without a password included; an active second factor is still asked for.
|
|
102
|
+
A challenge lives ten minutes (`tokens.signInCode`) and takes five
|
|
103
|
+
attempts, and only the code's hash is stored, keyed by the challenge.
|
|
104
|
+
- **A TOTP second factor, v0.5.0** — `janus({ secondFactor: { issuer, keys } })`
|
|
105
|
+
and `auth.<type>.secondFactor`'s `enroll`, `activate`, `disable` and
|
|
106
|
+
`confirm`, for every user type with a password; each secret sealed with
|
|
107
|
+
AES-256-GCM under keys your application holds, and rotated by adding a key.
|
|
108
|
+
Breaking once configured: `signIn` answers `{ status: 'signedIn', … }` or
|
|
109
|
+
`{ status: 'secondFactor', challenge, expiresAt }`. A challenge lives five
|
|
110
|
+
minutes and takes five attempts, a code is accepted once, and a refused one
|
|
111
|
+
throws `CODE_INVALID` with `attemptsLeft`.
|
|
89
112
|
- **The store port holds a second factor and counts attempts on a token,
|
|
90
113
|
v0.4.0** — `UserRecord.secondFactor`, a token's `codeHash` and `attempts`,
|
|
91
114
|
the token kinds `'secondFactor'` and `'signInCode'`, and
|
|
92
115
|
`TokenStore.countAttempt`, one conditional write per attempt, with six new
|
|
93
|
-
conformance cases.
|
|
94
|
-
|
|
116
|
+
conformance cases. The published adapters implement them in
|
|
117
|
+
`@nxgt/janus-drizzle` 0.2, `@nxgt/janus-mongo` 0.3 and
|
|
95
118
|
`@nxgt/janus-redis` 0.2.
|
|
96
119
|
- **Permissions on a user, v0.3.0** — a user type may also be an object type:
|
|
97
120
|
a staff member is the object `can()` asks about and is granted relations
|
|
@@ -109,7 +132,10 @@ Each entry names the version it came in.
|
|
|
109
132
|
audit trail, never a login, a password, a session token or a one-time token;
|
|
110
133
|
and [`@nxgt/janus-kit`](https://www.npmjs.com/package/@nxgt/janus-kit), all
|
|
111
134
|
of it wired in one call.
|
|
112
|
-
|
|
135
|
+
- **A NUL character or a lone surrogate never reaches a store** — refused in
|
|
136
|
+
fields with `USER_INVALID` on every adapter, rather than `STORE_FAILED` on
|
|
137
|
+
PostgreSQL alone; a login holding one is nobody's. The conformance suite
|
|
138
|
+
holds every adapter to round-tripping every other character. — v0.2.1
|
|
113
139
|
- **The model's keys read `related` and `permits`** — Keto's OPL words: an
|
|
114
140
|
object type declares `related: { members: ['staff', 'team#members'] }` and
|
|
115
141
|
`permits: { view: ['members'] }`, and relation names are plural by convention. Breaking:
|
|
@@ -123,48 +149,7 @@ Each entry names the version it came in.
|
|
|
123
149
|
- **The conformance suite accepts a store with its own expiry** — a store
|
|
124
150
|
that drops a lapsed session at once, as a Redis TTL does, passes
|
|
125
151
|
`sessions.deleteUser`; one that still holds it must count it. — v0.2.0
|
|
126
|
-
- **A NUL character or a lone surrogate never reaches a store** — refused in
|
|
127
|
-
fields with `USER_INVALID` on every adapter, rather than `STORE_FAILED` on
|
|
128
|
-
PostgreSQL alone; a login holding one is nobody's. The conformance suite
|
|
129
|
-
holds every adapter to round-tripping every other character. — v0.2.1
|
|
130
|
-
|
|
131
152
|
- **A Hono integration** — [`@nxgt/janus-hono`](https://www.npmjs.com/package/@nxgt/janus-hono):
|
|
132
153
|
the session middleware, the cookie, a route guarded by a permission,
|
|
133
154
|
`bindJanus()` to bind the instances once, and every error as its status.
|
|
134
155
|
Its own 0.1.0, beside `@nxgt/janus` 0.1.3.
|
|
135
|
-
- **`defineModel` completed by your editor** — subject types and subject sets
|
|
136
|
-
in a relation, subject types in `fromField`, relations, permissions and
|
|
137
|
-
arrows in a rule and in `when`; a wrong name's error lists the names it
|
|
138
|
-
could have been. — v0.1.2
|
|
139
|
-
|
|
140
|
-
- **The model decides what a stored tuple grants** — `can()` and `list()` follow
|
|
141
|
-
only the holders a relation admits, as `grant()` writes only those: a tuple
|
|
142
|
-
stored past `grant()`, by an older model or by hand, grants nothing. — v0.1
|
|
143
|
-
- **Guides and troubleshooting pages** — a `docs/` folder shipped in the
|
|
144
|
-
package: detailed guides with examples, and the errors you can meet, each
|
|
145
|
-
with its cause and fix. — v0.1
|
|
146
|
-
- **Permissions at `@nxgt/janus/permissions`** — and `janus({ relations })`,
|
|
147
|
-
so deleting a user also deletes every tuple naming them. — v0.1
|
|
148
|
-
- **`list()`** — the ids of every object a subject holds a permission on, as a
|
|
149
|
-
cursor page, `fromField` relations included through their `lookup`. — v0.1
|
|
150
|
-
- **`can()`, `grant()` and `revoke()`** — a permission check that answers
|
|
151
|
-
`true` or `false` and throws on an outage, and tuple writes refused at
|
|
152
|
-
compile time when the model does not admit them. — v0.1
|
|
153
|
-
- **Typed subjects and the `RelationStore` port** — `{ type, id }` subjects,
|
|
154
|
-
the tuple notation, `createMemoryRelations()`, and
|
|
155
|
-
`describeRelationStores` for adapter authors. — v0.1
|
|
156
|
-
- **A permission model typed from itself** — `defineModel` with subject sets,
|
|
157
|
-
arrows, `fromField` relations read from your data, and `when` conditions
|
|
158
|
-
written in TypeScript. — v0.1
|
|
159
|
-
- **Delete a user, and everything of theirs** — `delete(user)` removes the
|
|
160
|
-
user with every session and one-time token they had, idempotently. — v0.1
|
|
161
|
-
- **Rehash a stale password on sign-in** — moving hashers, or raising a cost,
|
|
162
|
-
reaches every active user with no migration to run. — v0.1
|
|
163
|
-
- **`janus()`** — sign-up, sign-in, sessions, e-mail verification and password
|
|
164
|
-
reset, with several user types in one instance, typed from your schemas.
|
|
165
|
-
— v0.1
|
|
166
|
-
- **The conformance suite** — `@nxgt/janus/conformance`, the suite an adapter
|
|
167
|
-
runs, outages included. — v0.1
|
|
168
|
-
- **The identity stores' port and its in-memory reference** — `JanusStores` and
|
|
169
|
-
`createMemoryStores()`, for your tests and as the model for an adapter.
|
|
170
|
-
— 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,26 @@ 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
|
+
|
|
77
|
+
**Sign-in codes**
|
|
78
|
+
- [`TOKEN_STALE` — `<call>: the code was sent to an e-mail the user no longer has`](#token_stale--call-the-code-was-sent-to-an-e-mail-the-user-no-longer-has)
|
|
79
|
+
- [The code from an earlier e-mail is refused with `CODE_INVALID`](#the-code-from-an-earlier-e-mail-is-refused-with-code_invalid)
|
|
80
|
+
- [`signInCode.request` answers `null` for a user who exists](#signincoderequest-answers-null-for-a-user-who-exists)
|
|
81
|
+
- [`TS2339: Property 'signInCode' does not exist on type 'TypeApi<…>'.`](#ts2339-property-signincode-does-not-exist-on-type-typeapi)
|
|
82
|
+
- `CODE_INVALID`, `TOKEN_UNKNOWN`, `TOKEN_SPENT`, `TOKEN_EXPIRED`, `USER_INACTIVE` and `VERSION_CONFLICT` from `signInCode.confirm`, and `TS2339` on its `token`: in their entries above.
|
|
83
|
+
|
|
61
84
|
**Permissions**
|
|
62
85
|
- [`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
86
|
- [`permissions: this model was not made by defineModel() …`](#permissions-this-model-was-not-made-by-definemodel--pass-what-definemodel-answered)
|
|
@@ -73,6 +96,7 @@ How the messages are shaped:
|
|
|
73
96
|
|
|
74
97
|
**Subjects**
|
|
75
98
|
- [`parseTuple: "<text>" is not a relation tuple; expected type:id#relation@subject`](#parsetuple-text-is-not-a-relation-tuple-expected-typeidrelationsubject)
|
|
99
|
+
- [`setOf: pass a user or { type, id }, then a relation`](#setof-pass-a-user-or--type-id--then-a-relation)
|
|
76
100
|
|
|
77
101
|
**Conformance (adapter authors)**
|
|
78
102
|
- [`describeJanusStores: no test runner on globalThis …`](#describejanusstores-no-test-runner-on-globalthis--pass-runner--describe-it--under-bun-test-import-them-from-buntest)
|
|
@@ -274,7 +298,7 @@ Also `janus: "<name>" cannot name a user type — janus() answers a method of th
|
|
|
274
298
|
|
|
275
299
|
### `janus: session.lifespan: "<value>" is not a duration; write a number followed by ms, s, m, h or d — for example "15m" or "720h"`
|
|
276
300
|
|
|
277
|
-
The same for `session.renewAfter`, `tokens.verifyEmail` and `
|
|
301
|
+
The same for `session.renewAfter`, `tokens.verifyEmail`, `tokens.resetPassword`, `tokens.signInCode` and `secondFactor.challenge`. Also `<option>: a duration must be above zero` and `<option>: a duration in milliseconds must be a finite number above zero`.
|
|
278
302
|
|
|
279
303
|
**When:** `janus({...})`.
|
|
280
304
|
**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.
|
|
@@ -292,6 +316,48 @@ Also `janus: cookie.name must be a cookie-name token — letters, digits and !#$
|
|
|
292
316
|
**Why:** browsers drop a `SameSite=None` cookie that is not `Secure`, so every sign-in would silently fail to stick.
|
|
293
317
|
**Fix:** keep `secure: true` with `sameSite: 'none'`, or use the default `sameSite: 'lax'` when the site and the API share a site.
|
|
294
318
|
|
|
319
|
+
### `janus: secondFactor.issuer must name your application — the authenticator app shows it beside the account`
|
|
320
|
+
|
|
321
|
+
**When:** `janus({ ..., secondFactor })`, when `issuer` is missing, is not a string, or is blank.
|
|
322
|
+
**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.
|
|
323
|
+
**Fix:**
|
|
324
|
+
|
|
325
|
+
```ts
|
|
326
|
+
janus({ ..., secondFactor: { issuer: 'Acme', keys } });
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
### `janus: secondFactor.keys: the key "<id>" is not 32 bytes in base64 — make one with openssl rand -base64 32`
|
|
330
|
+
|
|
331
|
+
Also:
|
|
332
|
+
|
|
333
|
+
- `janus: secondFactor.keys: expected at least one key — [{ id, key }], the first seals`
|
|
334
|
+
- `janus: secondFactor.keys: every key needs an id of letters, digits, _ and -, at most 64 of them`
|
|
335
|
+
- `janus: secondFactor.keys: two keys have the id "<id>"`
|
|
336
|
+
|
|
337
|
+
**When:** `janus({ ..., secondFactor })`.
|
|
338
|
+
**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.
|
|
339
|
+
**Fix:** make each key once and keep it with your other secrets:
|
|
340
|
+
|
|
341
|
+
```sh
|
|
342
|
+
openssl rand -base64 32
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
```ts
|
|
346
|
+
janus({
|
|
347
|
+
...,
|
|
348
|
+
secondFactor: {
|
|
349
|
+
issuer: 'Acme',
|
|
350
|
+
keys: [{ id: 'k2026a', key: process.env.TOTP_KEY_K2026A ?? '' }], // the first key seals
|
|
351
|
+
},
|
|
352
|
+
});
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
### `"hasSecondFactor" is a field janus sets itself; rename it`
|
|
356
|
+
|
|
357
|
+
**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.
|
|
358
|
+
**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`.
|
|
359
|
+
**Fix:** rename the field in your schema, and read `user.hasSecondFactor` for janus's answer.
|
|
360
|
+
|
|
295
361
|
### Other `janus:` wiring messages
|
|
296
362
|
|
|
297
363
|
| Message | Fix |
|
|
@@ -365,6 +431,23 @@ const user = await auth.get(id);
|
|
|
365
431
|
await auth.update(user, { name }, { ifVersion: user.version });
|
|
366
432
|
```
|
|
367
433
|
|
|
434
|
+
**From `secondFactor.confirm`**, the message is the store's:
|
|
435
|
+
`updateUser: expected version <n>, found <m>`.
|
|
436
|
+
|
|
437
|
+
**When:** two `confirm` calls for one user run at once with the same code — a
|
|
438
|
+
double-submitted form, two tabs, a client that retries before the first answer.
|
|
439
|
+
**Why:** of two codes accepted at once, the first write wins and opens a
|
|
440
|
+
session. The second finds the user's version moved and writes nothing: no
|
|
441
|
+
session. Its challenge is not spent, and has lost one attempt.
|
|
442
|
+
**Fix:** submit the code form once. The session already exists, from the call
|
|
443
|
+
that won; if the visitor still needs one, the next code works on the same
|
|
444
|
+
challenge — the same code will not, it was used.
|
|
445
|
+
|
|
446
|
+
```ts
|
|
447
|
+
// in the browser: one submit per code
|
|
448
|
+
form.addEventListener('submit', () => form.querySelector('button')?.setAttribute('disabled', ''));
|
|
449
|
+
```
|
|
450
|
+
|
|
368
451
|
### `USER_INVALID` — `<call>: the fields do not match the <type> schema (<n> issues, at <paths>)`
|
|
369
452
|
|
|
370
453
|
`UserInvalidError`, carrying `issues` — each a `path` and a `message`.
|
|
@@ -416,9 +499,9 @@ janus({ ..., hasher: scryptHasher(), verifiers: [bcryptVerifier] }); // a Passwo
|
|
|
416
499
|
|
|
417
500
|
### `USER_INACTIVE` — `<call>: the user is inactive`
|
|
418
501
|
|
|
419
|
-
**When:** `signIn`, with the **right** password, for a user set inactive.
|
|
420
|
-
**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.
|
|
421
|
-
**Fix:** answer 403, or reactivate: `await auth.setActive(user, true)`.
|
|
502
|
+
**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, and `signInCode.confirm`, for a user set inactive after the code was sent.
|
|
503
|
+
**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 — and after the code, so only somebody who read the e-mail does: `signInCode.request` answers `null` for an inactive user, as for nobody. On either `confirm`, the challenge is spent: reactivating the user does not revive it.
|
|
504
|
+
**Fix:** answer 403, or reactivate, then sign in again: `await auth.setActive(user, true)`.
|
|
422
505
|
|
|
423
506
|
### `TOKEN_UNKNOWN`, `TOKEN_SPENT`, `TOKEN_EXPIRED`
|
|
424
507
|
|
|
@@ -432,6 +515,46 @@ janus({ ..., hasher: scryptHasher(), verifiers: [bcryptVerifier] }); // a Passwo
|
|
|
432
515
|
janus({ ..., tokens: { verifyEmail: '72h', resetPassword: '2h' } });
|
|
433
516
|
```
|
|
434
517
|
|
|
518
|
+
**On `secondFactor.confirm`**, the messages name the challenge `signIn`
|
|
519
|
+
answered: `secondFactor.confirm: no such challenge`,
|
|
520
|
+
`secondFactor.confirm: the challenge was already used`,
|
|
521
|
+
`secondFactor.confirm: the challenge has expired`.
|
|
522
|
+
|
|
523
|
+
**When:** `secondFactor.confirm(challenge, code)`.
|
|
524
|
+
**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, and by a refusal
|
|
526
|
+
that ends it (`USER_INACTIVE`, `SECOND_FACTOR_NOT_ENROLLED`). `TOKEN_UNKNOWN`
|
|
527
|
+
also covers a challenge whose user was deleted, and a challenge passed where a
|
|
528
|
+
code was expected — the two arguments swapped.
|
|
529
|
+
**Fix:** answer 400 and send the visitor back to sign in, which asks for a new
|
|
530
|
+
code. To give slower visitors more time:
|
|
531
|
+
|
|
532
|
+
```ts
|
|
533
|
+
janus({ ..., secondFactor: { issuer: 'Acme', keys, challenge: '10m' } });
|
|
534
|
+
```
|
|
535
|
+
|
|
536
|
+
**On `signInCode.confirm`**, the messages name the challenge
|
|
537
|
+
`signInCode.request` answered: `signInCode.confirm: no such challenge`,
|
|
538
|
+
`signInCode.confirm: the challenge was already used`,
|
|
539
|
+
`signInCode.confirm: the challenge has expired`.
|
|
540
|
+
|
|
541
|
+
**When:** `signInCode.confirm(challenge, code)`.
|
|
542
|
+
**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, and by a refusal
|
|
544
|
+
that ends it (`TOKEN_STALE`, `USER_INACTIVE`). `TOKEN_UNKNOWN` also covers a
|
|
545
|
+
challenge whose user was deleted, one confirmed through another user type's
|
|
546
|
+
`signInCode`, the decoy challenge of a `request` that answered `null`, and
|
|
547
|
+
the two arguments swapped. Another type's `confirm` compares no code and
|
|
548
|
+
spends nothing, but it has already cost one of the challenge's five
|
|
549
|
+
attempts: the attempt is counted before the type is known. The challenge is
|
|
550
|
+
left for its own type, with one attempt fewer.
|
|
551
|
+
**Fix:** answer 400 and offer to send a new code. To give slower inboxes
|
|
552
|
+
more time:
|
|
553
|
+
|
|
554
|
+
```ts
|
|
555
|
+
janus({ ..., tokens: { signInCode: '15m' } });
|
|
556
|
+
```
|
|
557
|
+
|
|
435
558
|
### `TOKEN_STALE` — `<call>: the token was sent to an e-mail the user no longer has`
|
|
436
559
|
|
|
437
560
|
**When:** `verifyEmail.confirm` or `resetPassword.confirm`, after the user changed their e-mail.
|
|
@@ -486,6 +609,292 @@ const page = await access.list(user, 'view', 'record', {
|
|
|
486
609
|
|
|
487
610
|
---
|
|
488
611
|
|
|
612
|
+
## Second factor
|
|
613
|
+
|
|
614
|
+
Every entry here needs `janus({ ..., secondFactor })`, but for the
|
|
615
|
+
`signInCode.confirm` paragraph of `CODE_INVALID`. The messages start with
|
|
616
|
+
`secondFactor.enroll`, `secondFactor.activate`, `secondFactor.confirm` or
|
|
617
|
+
`signIn` — prefixed by the type with several user types:
|
|
618
|
+
`staff.secondFactor.confirm: …`. `secondFactor.confirm` also rejects with
|
|
619
|
+
[`TOKEN_UNKNOWN`, `TOKEN_SPENT`, `TOKEN_EXPIRED`](#token_unknown-token_spent-token_expired),
|
|
620
|
+
[`USER_INACTIVE`](#user_inactive--call-the-user-is-inactive) and
|
|
621
|
+
[`VERSION_CONFLICT`](#version_conflict--call-expected-version-n-found-m);
|
|
622
|
+
each of those entries has a paragraph for it.
|
|
623
|
+
|
|
624
|
+
### `TS2339: Property 'token' does not exist on type 'SignInResult<…>'.`
|
|
625
|
+
|
|
626
|
+
The same for `session` and `user`.
|
|
627
|
+
|
|
628
|
+
**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.
|
|
630
|
+
**Fix:** switch on `status`:
|
|
631
|
+
|
|
632
|
+
```ts
|
|
633
|
+
const result = await auth.signIn({ email, password });
|
|
634
|
+
if (result.status === 'secondFactor') {
|
|
635
|
+
// Keep the challenge for the next request — never in a URL — and ask for a code.
|
|
636
|
+
return Response.json({ challenge: result.challenge, expiresAt: result.expiresAt });
|
|
637
|
+
}
|
|
638
|
+
return Response.json({ token: result.token });
|
|
639
|
+
|
|
640
|
+
// the next request, with the code the app shows
|
|
641
|
+
const signedIn = await auth.secondFactor.confirm(challenge, code); // { status: 'signedIn', token, … }
|
|
642
|
+
```
|
|
643
|
+
|
|
644
|
+
### `CODE_INVALID` — `<call>: the code does not match, or was already used`
|
|
645
|
+
|
|
646
|
+
`TokenError`. The same message answers three calls; there is a paragraph
|
|
647
|
+
for each below.
|
|
648
|
+
|
|
649
|
+
**When:** `secondFactor.activate`, `secondFactor.confirm` or
|
|
650
|
+
`signInCode.confirm`, with a code that does not match.
|
|
651
|
+
|
|
652
|
+
**`secondFactor.activate(user, code)`**
|
|
653
|
+
**Why:** the code is wrong, or it was accepted before: a code is accepted
|
|
654
|
+
once. There is no challenge here: no `attemptsLeft`, and nothing is spent.
|
|
655
|
+
**Fix:** let the user try the next code the app shows. If it is the one on
|
|
656
|
+
screen, see [the next entry](#the-code-the-authenticator-app-shows-is-refused-with-code_invalid).
|
|
657
|
+
|
|
658
|
+
**`secondFactor.confirm(challenge, code)`**
|
|
659
|
+
**Why:** the code is wrong, or it was accepted before. Every call costs one of
|
|
660
|
+
the challenge's five attempts, counted before anything is checked. The error
|
|
661
|
+
carries `attemptsLeft`, what remains; at `0`, the challenge is spent and the
|
|
662
|
+
next `confirm` is `TOKEN_SPENT`.
|
|
663
|
+
**Fix:** answer 401 with `attemptsLeft`, and send the visitor back to sign in
|
|
664
|
+
once it is `0`:
|
|
665
|
+
|
|
666
|
+
```ts
|
|
667
|
+
import { TokenError } from '@nxgt/janus';
|
|
668
|
+
|
|
669
|
+
try {
|
|
670
|
+
return await auth.secondFactor.confirm(challenge, code);
|
|
671
|
+
} catch (error) {
|
|
672
|
+
if (error instanceof TokenError && error.code === 'CODE_INVALID') {
|
|
673
|
+
return Response.json({ code: error.code, attemptsLeft: error.attemptsLeft }, { status: 401 });
|
|
674
|
+
}
|
|
675
|
+
throw error;
|
|
676
|
+
}
|
|
677
|
+
```
|
|
678
|
+
|
|
679
|
+
**`signInCode.confirm(challenge, code)`** — the e-mailed code.
|
|
680
|
+
**Why:** the code is not the one sent with this challenge, or is not six
|
|
681
|
+
digits — a space, a dash, a code pasted with its label. The error carries
|
|
682
|
+
`attemptsLeft` and the `userId` of the user the code was sent to. Every call
|
|
683
|
+
costs one of the challenge's five attempts, counted before the code is
|
|
684
|
+
compared — a call through another user type's `signInCode` too, although it
|
|
685
|
+
answers `TOKEN_UNKNOWN`. At `0` the challenge is spent, and the next
|
|
686
|
+
`confirm` is `TOKEN_SPENT`, even with the right code. Codes sent at once past
|
|
687
|
+
the fifth attempt are all refused, the right one included.
|
|
688
|
+
**Fix:** answer 401 with `attemptsLeft`, and request a new code once it is
|
|
689
|
+
`0`. Strip what the visitor may have typed around the digits before calling
|
|
690
|
+
`confirm`:
|
|
691
|
+
|
|
692
|
+
```ts
|
|
693
|
+
import { TokenError } from '@nxgt/janus';
|
|
694
|
+
|
|
695
|
+
try {
|
|
696
|
+
return await auth.signInCode.confirm(challenge, code.replace(/\D/g, ''));
|
|
697
|
+
} catch (error) {
|
|
698
|
+
if (error instanceof TokenError && error.code === 'CODE_INVALID') {
|
|
699
|
+
return Response.json({ code: error.code, attemptsLeft: error.attemptsLeft }, { status: 401 });
|
|
700
|
+
}
|
|
701
|
+
throw error;
|
|
702
|
+
}
|
|
703
|
+
```
|
|
704
|
+
|
|
705
|
+
If the visitor typed the code from the e-mail correctly, see
|
|
706
|
+
[the code from an earlier e-mail](#the-code-from-an-earlier-e-mail-is-refused-with-code_invalid).
|
|
707
|
+
|
|
708
|
+
### The code the authenticator app shows is refused with `CODE_INVALID`
|
|
709
|
+
|
|
710
|
+
**When:** `activate` or `confirm`, with the code on screen, typed correctly.
|
|
711
|
+
**Why**, in the order to check:
|
|
712
|
+
|
|
713
|
+
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.
|
|
714
|
+
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.
|
|
715
|
+
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.
|
|
716
|
+
|
|
717
|
+
**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`.
|
|
718
|
+
|
|
719
|
+
```sh
|
|
720
|
+
timedatectl show -p NTPSynchronized # NTPSynchronized=yes on the server
|
|
721
|
+
```
|
|
722
|
+
|
|
723
|
+
### `SECOND_FACTOR_NOT_ENROLLED` — `secondFactor.activate: the user has no second factor waiting — call enroll first`
|
|
724
|
+
|
|
725
|
+
`SecondFactorError`. Also `secondFactor.confirm: the user no longer has a second factor — sign in again`.
|
|
726
|
+
|
|
727
|
+
**When:** `activate` before `enroll`, or after `disable`. On `confirm`: the factor was disabled after `signIn` asked for a code.
|
|
728
|
+
**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.
|
|
729
|
+
**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.
|
|
730
|
+
|
|
731
|
+
```ts
|
|
732
|
+
const { secret, uri } = await auth.secondFactor.enroll(user);
|
|
733
|
+
// …the user scans uri, or types secret…
|
|
734
|
+
await auth.secondFactor.activate(user, code);
|
|
735
|
+
```
|
|
736
|
+
|
|
737
|
+
### `SECOND_FACTOR_ACTIVE` — `secondFactor.enroll: the user's second factor is active — disable it first`
|
|
738
|
+
|
|
739
|
+
`SecondFactorError`. Also `secondFactor.activate: the user's second factor is already active`.
|
|
740
|
+
|
|
741
|
+
**When:** `enroll` or `activate` for a user whose factor is already active — often a form submitted twice, or a "set up again" button.
|
|
742
|
+
**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.
|
|
743
|
+
**Fix:** check `user.hasSecondFactor` first. To move to a new phone, disable, then enroll:
|
|
744
|
+
|
|
745
|
+
```ts
|
|
746
|
+
const cleared = await auth.secondFactor.disable(user);
|
|
747
|
+
const { uri } = await auth.secondFactor.enroll(cleared);
|
|
748
|
+
```
|
|
749
|
+
|
|
750
|
+
### `<call>: …, and janus() was given no secondFactor — pass secondFactor: { issuer, keys }`
|
|
751
|
+
|
|
752
|
+
Most often: `signIn: the user's second factor is active, and janus() was given no secondFactor — pass secondFactor: { issuer, keys }`. A `TypeError`.
|
|
753
|
+
|
|
754
|
+
**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.
|
|
755
|
+
**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.
|
|
756
|
+
**Fix:** give every `janus()` over the same users the same `secondFactor`, from one module:
|
|
757
|
+
|
|
758
|
+
```ts
|
|
759
|
+
// auth-config.ts, imported by every process
|
|
760
|
+
export const secondFactor = { issuer: 'Acme', keys } as const;
|
|
761
|
+
|
|
762
|
+
janus({ ..., secondFactor });
|
|
763
|
+
```
|
|
764
|
+
|
|
765
|
+
### `<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`
|
|
766
|
+
|
|
767
|
+
A `TypeError`.
|
|
768
|
+
|
|
769
|
+
**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.
|
|
770
|
+
**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.
|
|
771
|
+
**Fix:** put the old key back, after the new one:
|
|
772
|
+
|
|
773
|
+
```ts
|
|
774
|
+
secondFactor: {
|
|
775
|
+
issuer: 'Acme',
|
|
776
|
+
keys: [
|
|
777
|
+
{ id: 'k2026b', key: process.env.TOTP_KEY_K2026B ?? '' }, // seals
|
|
778
|
+
{ id: 'k2026a', key: process.env.TOTP_KEY_K2026A ?? '' }, // still opens
|
|
779
|
+
],
|
|
780
|
+
},
|
|
781
|
+
```
|
|
782
|
+
|
|
783
|
+
A key can go once no stored second-factor secret starts with `v1.<its id>.`.
|
|
784
|
+
|
|
785
|
+
### `<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?`
|
|
786
|
+
|
|
787
|
+
A `TypeError`.
|
|
788
|
+
|
|
789
|
+
**When:** `activate` or `confirm`.
|
|
790
|
+
**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.
|
|
791
|
+
**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)`.
|
|
792
|
+
|
|
793
|
+
### `<call>: the stored secret is not a sealed one`
|
|
794
|
+
|
|
795
|
+
A `TypeError`.
|
|
796
|
+
|
|
797
|
+
**When:** `activate` or `confirm`.
|
|
798
|
+
**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.
|
|
799
|
+
**Fix:** never write the second factor into the store yourself. Disable it and have the user enroll again:
|
|
800
|
+
|
|
801
|
+
```ts
|
|
802
|
+
await auth.secondFactor.disable(user);
|
|
803
|
+
```
|
|
804
|
+
|
|
805
|
+
### `<call>: the <type> type does not sign in with a password, so it has no second factor`
|
|
806
|
+
|
|
807
|
+
A `TypeError`.
|
|
808
|
+
|
|
809
|
+
**When:** `secondFactor.enroll`, from JavaScript, on a user type without `password`. In TypeScript, `secondFactor` is absent from such a type.
|
|
810
|
+
**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.
|
|
811
|
+
**Fix:** enroll only users of a type with `password: { login }`.
|
|
812
|
+
|
|
813
|
+
---
|
|
814
|
+
|
|
815
|
+
## Sign-in codes
|
|
816
|
+
|
|
817
|
+
The messages start with `signInCode.confirm` — prefixed by the type with
|
|
818
|
+
several user types: `patient.signInCode.confirm: …`. `signInCode.request`
|
|
819
|
+
throws nothing but `STORE_FAILED`: an e-mail it cannot sign in is `null`.
|
|
820
|
+
`signInCode.confirm` also rejects with
|
|
821
|
+
[`CODE_INVALID`](#code_invalid--call-the-code-does-not-match-or-was-already-used),
|
|
822
|
+
[`TOKEN_UNKNOWN`, `TOKEN_SPENT`, `TOKEN_EXPIRED`](#token_unknown-token_spent-token_expired),
|
|
823
|
+
[`USER_INACTIVE`](#user_inactive--call-the-user-is-inactive) and
|
|
824
|
+
[`VERSION_CONFLICT`](#version_conflict--call-expected-version-n-found-m);
|
|
825
|
+
each of those entries has a paragraph for it.
|
|
826
|
+
[The sign-in code guide](guide/sign-in-code.md) has the whole flow.
|
|
827
|
+
|
|
828
|
+
### `TOKEN_STALE` — `<call>: the code was sent to an e-mail the user no longer has`
|
|
829
|
+
|
|
830
|
+
`TokenError`, carrying the `userId`.
|
|
831
|
+
|
|
832
|
+
**When:** `signInCode.confirm`, after the user's e-mail was changed — by
|
|
833
|
+
`update`, or a patch naming the e-mail field — since the code was sent.
|
|
834
|
+
**Why:** the code proves an address, and signing in with it would mark as
|
|
835
|
+
verified an address the user no longer holds. The challenge is spent.
|
|
836
|
+
**Fix:** answer 400, and request a new code: it goes to the current address.
|
|
837
|
+
|
|
838
|
+
```ts
|
|
839
|
+
const issued = await auth.signInCode.request(currentEmail);
|
|
840
|
+
```
|
|
841
|
+
|
|
842
|
+
### The code from an earlier e-mail is refused with `CODE_INVALID`
|
|
843
|
+
|
|
844
|
+
**When:** the visitor asked for a code twice — pressed "send again", or
|
|
845
|
+
opened the form in two tabs — and typed the code of the first e-mail.
|
|
846
|
+
**Why:** every `request` issues a new code **with its own challenge**, and a
|
|
847
|
+
code is checked against the challenge it was sent with. The cookie or the
|
|
848
|
+
form field now holds the second challenge, so the first code does not
|
|
849
|
+
match it — and costs an attempt.
|
|
850
|
+
**Fix:** tell the visitor that only the last code sent works, and put the
|
|
851
|
+
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 not revoked: it still expires on its
|
|
853
|
+
own.
|
|
854
|
+
|
|
855
|
+
### `signInCode.request` answers `null` for a user who exists
|
|
856
|
+
|
|
857
|
+
**When:** `signInCode.request(email)`, for an address you can see in the
|
|
858
|
+
database.
|
|
859
|
+
**Why**, in the order to check:
|
|
860
|
+
|
|
861
|
+
1. **The user is inactive.** An inactive user gets no code, and the answer
|
|
862
|
+
is the same as for nobody.
|
|
863
|
+
2. **It is another user type.** `clinic.patient.signInCode.request` looks
|
|
864
|
+
among patients only; the same address may hold a user of another type.
|
|
865
|
+
3. **The address is not the type's e-mail field.** A type whose `email`
|
|
866
|
+
option names `contact` is looked up by `contact`. A login that looks like
|
|
867
|
+
an e-mail — a `username` of `ada@example.com` — is not an e-mail, and is
|
|
868
|
+
`null` too.
|
|
869
|
+
|
|
870
|
+
The e-mail is trimmed and lowercased before the lookup, so case and
|
|
871
|
+
surrounding spaces are never the cause.
|
|
872
|
+
**Fix:** `setActive(user, true)`; call the right type's `signInCode`; name
|
|
873
|
+
the field with `email: 'contact'`. Do not tell the visitor which case it
|
|
874
|
+
was — the route answers the same page either way.
|
|
875
|
+
|
|
876
|
+
### `TS2339: Property 'signInCode' does not exist on type 'TypeApi<…>'.`
|
|
877
|
+
|
|
878
|
+
Also `Property 'signInCode' does not exist on type 'Janus<…>'.`, with the
|
|
879
|
+
single-type form, `janus({ user })`.
|
|
880
|
+
|
|
881
|
+
**When:** `tsc`, on `auth.signInCode` or `clinic.<type>.signInCode`.
|
|
882
|
+
**Why:** the user type has no e-mail: no field called `email`, and no
|
|
883
|
+
`email` option naming one. A code has nowhere to be sent, so the flow is
|
|
884
|
+
absent from the type.
|
|
885
|
+
**Fix:** name the field that holds the e-mail:
|
|
886
|
+
|
|
887
|
+
```ts
|
|
888
|
+
janus({
|
|
889
|
+
users: {
|
|
890
|
+
staff: { schema: Staff, password: { login: 'username' }, email: 'workEmail' },
|
|
891
|
+
},
|
|
892
|
+
...
|
|
893
|
+
});
|
|
894
|
+
```
|
|
895
|
+
|
|
896
|
+
---
|
|
897
|
+
|
|
489
898
|
## Permissions
|
|
490
899
|
|
|
491
900
|
### `PERMISSION_DEPTH` — `can: checking <type>#<permission> crossed more than <n> relations without an answer`
|
package/package.json
CHANGED
|
File without changes
|
|
File without changes
|
|
File without changes
|