@nxgt/janus 0.14.0 → 0.16.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 +109 -20
- package/dist/auth/config/janus-config.d.ts +2 -0
- package/dist/auth/config/janus-config.d.ts.map +1 -1
- package/dist/auth/config/resolved-config.d.ts +1 -0
- package/dist/auth/config/resolved-config.d.ts.map +1 -1
- package/dist/auth/email-sign-in.d.ts +45 -0
- package/dist/auth/email-sign-in.d.ts.map +1 -0
- package/dist/auth/events.d.ts +10 -4
- package/dist/auth/events.d.ts.map +1 -1
- package/dist/auth/first-proof.d.ts +37 -0
- package/dist/auth/first-proof.d.ts.map +1 -0
- package/dist/auth/index.d.ts +1 -1
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/magic-link.d.ts +22 -0
- package/dist/auth/magic-link.d.ts.map +1 -0
- package/dist/auth/port/types/tokens.d.ts +6 -3
- package/dist/auth/port/types/tokens.d.ts.map +1 -1
- package/dist/auth/sign-in-code.d.ts +1 -3
- package/dist/auth/sign-in-code.d.ts.map +1 -1
- package/dist/auth/types/email-flows.d.ts +7 -4
- package/dist/auth/types/email-flows.d.ts.map +1 -1
- package/dist/auth/types/index.d.ts +1 -0
- package/dist/auth/types/index.d.ts.map +1 -1
- package/dist/auth/types/janus.d.ts +8 -1
- package/dist/auth/types/janus.d.ts.map +1 -1
- package/dist/auth/types/magic-link.d.ts +50 -0
- package/dist/auth/types/magic-link.d.ts.map +1 -0
- package/dist/auth/users/any-type-api.d.ts +2 -2
- package/dist/auth/users/any-type-api.d.ts.map +1 -1
- package/dist/auth/users/type-api.d.ts +2 -1
- package/dist/auth/users/type-api.d.ts.map +1 -1
- package/dist/conformance/cases/tokens/kinds.d.ts +4 -1
- package/dist/conformance/cases/tokens/kinds.d.ts.map +1 -1
- package/dist/conformance/index.js +23 -1
- package/dist/conformance/index.js.map +3 -3
- package/dist/index.js +173 -88
- package/dist/index.js.map +12 -9
- package/docs/README.md +2 -1
- package/docs/guide/adapters.md +11 -7
- package/docs/guide/email-flows.md +14 -8
- package/docs/guide/errors.md +2 -2
- package/docs/guide/events.md +5 -5
- package/docs/guide/magic-link.md +442 -0
- package/docs/guide/passwords.md +5 -4
- package/docs/guide/second-factor.md +28 -2
- package/docs/guide/sessions.md +4 -1
- package/docs/guide/sign-in-code.md +20 -7
- package/docs/guide/users.md +10 -6
- package/docs/guide/vocabulary.md +6 -5
- package/docs/roadmap.md +18 -12
- package/docs/troubleshooting.md +178 -12
- package/package.json +1 -1
|
@@ -127,7 +127,7 @@ Without `secondFactor`, `signIn` answers a session as before, typed
|
|
|
127
127
|
|
|
128
128
|
| State | `hasSecondFactor` | `signIn` answers | Recovery codes | Reached by |
|
|
129
129
|
| --- | --- | --- | --- | --- |
|
|
130
|
-
| none | `false` | a session | none | a new user; `disable`, which removes the codes with the factor |
|
|
130
|
+
| none | `false` | a session | none | a new user; `disable`, which removes the codes with the factor; the [first proof of the e-mail](#a-first-sign-in-by-e-mail-removes-it-too) by a sign-in code or link |
|
|
131
131
|
| **enrolled** — waiting for a first code | `false` | a session | none | `enroll` |
|
|
132
132
|
| **active** | `true` | a challenge | ten from `activate`; one fewer per `recover`; ten new from `regenerateRecoveryCodes` | `activate`, with a code that matches |
|
|
133
133
|
|
|
@@ -384,7 +384,7 @@ is as good as the user's phone.
|
|
|
384
384
|
### Signing in with one
|
|
385
385
|
|
|
386
386
|
`recover(challenge, code)` redeems the challenge `signIn` answered — or
|
|
387
|
-
`signInCode.confirm`'s — with a recovery code instead of the app's code, and
|
|
387
|
+
`signInCode.confirm`'s, or `magicLink.confirm`'s — with a recovery code instead of the app's code, and
|
|
388
388
|
opens the session. The recovery code is **spent**: its hash leaves the store,
|
|
389
389
|
and `recoveryCodesLeft` says how many remain.
|
|
390
390
|
|
|
@@ -559,6 +559,31 @@ const auth = janus({
|
|
|
559
559
|
});
|
|
560
560
|
```
|
|
561
561
|
|
|
562
|
+
### A first sign-in by e-mail removes it too
|
|
563
|
+
|
|
564
|
+
When [`signInCode.confirm`](sign-in-code.md) or
|
|
565
|
+
[`magicLink.confirm`](magic-link.md#an-account-someone-else-registered)
|
|
566
|
+
proves an e-mail **never verified**, the write that proves it also removes
|
|
567
|
+
the factor, active or enrolled, with its recovery codes — and drops the
|
|
568
|
+
password, and signs out every session. Whoever registered the address never
|
|
569
|
+
proved they held its inbox, so a factor they enrolled is not theirs to keep:
|
|
570
|
+
the inbox's owner signs in with no challenge.
|
|
571
|
+
|
|
572
|
+
```ts
|
|
573
|
+
const signedIn = await auth.magicLink.confirm(token); // an e-mail never verified
|
|
574
|
+
if (signedIn.status === 'signedIn' && !signedIn.user.hasSecondFactor) {
|
|
575
|
+
// offer setPassword, then enroll again
|
|
576
|
+
}
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
`user.secondFactorDisabled` is sent when the factor removed was active, after
|
|
580
|
+
`user.emailVerified` and any `user.passwordChanged`, so the listener above
|
|
581
|
+
tells the inbox's owner. An e-mail already verified keeps the factor, and
|
|
582
|
+
`confirm` answers a challenge as `signIn` does. **Send `verifyEmail` before
|
|
583
|
+
offering `enroll`**: a user who enrolls before proving their own e-mail loses
|
|
584
|
+
the factor on their first sign-in by code or link, since the library cannot
|
|
585
|
+
tell them from someone who registered their address.
|
|
586
|
+
|
|
562
587
|
### Asking before `enroll` and `disable` is your policy
|
|
563
588
|
|
|
564
589
|
`janus` checks nothing before `enroll` or `disable`: it does not know who is
|
|
@@ -874,6 +899,7 @@ with `STORE_FAILED`.
|
|
|
874
899
|
## See also
|
|
875
900
|
|
|
876
901
|
- [Sign-in codes](sign-in-code.md) — a sign-in by e-mailed code, which still asks for an active factor, with the same challenge
|
|
902
|
+
- [Sign-in links](magic-link.md) — a sign-in by e-mailed link, which does the same
|
|
877
903
|
- [Sessions](sessions.md) — the cookie `confirm`'s session is sent in, and `authenticatedAt`
|
|
878
904
|
- [User events](events.md) — `user.secondFactorEnabled`, `user.secondFactorDisabled`, `user.recoveryCodesRegenerated` and `user.recoveryCodeUsed`
|
|
879
905
|
- [Errors](errors.md) — `CODE_INVALID`, `SECOND_FACTOR_NOT_ENROLLED`, `SECOND_FACTOR_ACTIVE` and their statuses
|
package/docs/guide/sessions.md
CHANGED
|
@@ -182,7 +182,10 @@ if (current !== null) {
|
|
|
182
182
|
```
|
|
183
183
|
|
|
184
184
|
`changePassword` leaves other sessions open; call this after it when that is
|
|
185
|
-
your policy. `resetPassword.confirm` signs out everywhere on its own
|
|
185
|
+
your policy. `resetPassword.confirm` signs out everywhere on its own, and so
|
|
186
|
+
do `signInCode.confirm` and `magicLink.confirm` when they prove an e-mail
|
|
187
|
+
never verified, before opening their own session
|
|
188
|
+
([why](magic-link.md#an-account-someone-else-registered)).
|
|
186
189
|
|
|
187
190
|
## `collectExpired`
|
|
188
191
|
|
|
@@ -128,9 +128,9 @@ and the time the answer takes would tell what its body does not. The store's
|
|
|
128
128
|
own latency — one write for a code issued, none for `null` — still tells a
|
|
129
129
|
patient observer; that limit is stated rather than denied.
|
|
130
130
|
|
|
131
|
-
Every `request` issues a **new** code with its own challenge
|
|
132
|
-
ones
|
|
133
|
-
how often a code is asked for: **rate-limit the request route** per address
|
|
131
|
+
Every `request` issues a **new** code with its own challenge, and spends the
|
|
132
|
+
earlier ones: only the last code sent works — see
|
|
133
|
+
[attempts](#attempts). `janus` does not limit how often a code is asked for: **rate-limit the request route** per address
|
|
134
134
|
and per client, as you would a password reset, or anyone can fill a user's
|
|
135
135
|
inbox.
|
|
136
136
|
|
|
@@ -153,9 +153,10 @@ if (issued !== null) {
|
|
|
153
153
|
|
|
154
154
|
**No link, and no challenge.** A link carrying the challenge would put it in
|
|
155
155
|
the mailbox beside the code — whoever reads the e-mail would hold both
|
|
156
|
-
halves — and in every server log and proxy the link crosses. A sign-in link
|
|
157
|
-
is
|
|
158
|
-
sign-in completes in the browser that started
|
|
156
|
+
halves — and in every server log and proxy the link crosses. A [sign-in link](magic-link.md)
|
|
157
|
+
is another flow, with a token of its own: here the code is typed into the
|
|
158
|
+
page that asked for it, so the sign-in completes in the browser that started
|
|
159
|
+
it.
|
|
159
160
|
|
|
160
161
|
Send it to `issued.email`, not to what the visitor typed: it is the address
|
|
161
162
|
the user's field holds, as they registered it.
|
|
@@ -273,10 +274,21 @@ succeeds, and their `version` moves: the code proves the address as a
|
|
|
273
274
|
verification link would, and a [`user.emailVerified` event](events.md) is
|
|
274
275
|
sent. A user already verified is not written, and nothing is sent.
|
|
275
276
|
|
|
277
|
+
**An e-mail proved for the first time drops the password and the second
|
|
278
|
+
factor, and signs out every session** before the new one opens: the proof
|
|
279
|
+
can land on an account someone else registered, with a password — and maybe
|
|
280
|
+
a second factor — of theirs. `user.passwordChanged` follows
|
|
281
|
+
`user.emailVerified` when a password was dropped, then
|
|
282
|
+
`user.secondFactorDisabled` when an active factor was; an e-mail already
|
|
283
|
+
verified changes nothing. The details are in
|
|
284
|
+
[the sign-in link guide](magic-link.md#an-account-someone-else-registered):
|
|
285
|
+
the same holds for a code.
|
|
286
|
+
|
|
276
287
|
### A second factor is still asked for
|
|
277
288
|
|
|
278
289
|
The code proves the e-mail, not the second factor. With `janus({ secondFactor })`,
|
|
279
|
-
a user whose
|
|
290
|
+
a user whose e-mail was already verified and whose factor is active gets
|
|
291
|
+
**no session from the code**: `confirm`
|
|
280
292
|
answers a challenge, exactly as `signIn` does with a password, and
|
|
281
293
|
[`secondFactor.confirm`](second-factor.md#confirming-the-code-at-sign-in)
|
|
282
294
|
redeems it with the code of their authenticator app — or
|
|
@@ -499,6 +511,7 @@ a `secondFactor`, and `SignedIn<U>` everywhere else. `IssuedCode` and
|
|
|
499
511
|
## See also
|
|
500
512
|
|
|
501
513
|
- [The second factor](second-factor.md) — the challenge `confirm` answers for a user whose factor is active
|
|
514
|
+
- [Sign-in links](magic-link.md) — the same sign-in with a link instead of a code: nothing to type, on whichever device opens it
|
|
502
515
|
- [E-mail verification and password reset](email-flows.md) — the other flows that send an e-mail, and `TOKEN_STALE`
|
|
503
516
|
- [Sessions](sessions.md) — the cookie the session is sent in
|
|
504
517
|
- [Errors](errors.md) — every code and its status
|
package/docs/guide/users.md
CHANGED
|
@@ -82,7 +82,7 @@ that cuts an emoji in half, say.
|
|
|
82
82
|
| `password.login` | a field name | — | The field users sign in with: a **top-level, required string** field. A typo is a compile error |
|
|
83
83
|
| `password.normalize` | `'none' \| 'lowercase' \| 'lowercaseTrim' \| 'nfkcLowercaseTrim' \| (value) => string` | `'lowercaseTrim'` | Applied to the login before any store sees it, at sign-up and at sign-in alike |
|
|
84
84
|
| `password.minLength` | integer ≥ 1 | `8` | Below it: `PASSWORD_TOO_SHORT` |
|
|
85
|
-
| `email` | a field name | `'email'` | The field `verifyEmail`, `resetPassword` and `
|
|
85
|
+
| `email` | a field name | `'email'` | The field `verifyEmail`, `resetPassword`, `signInCode` and `magicLink` send to. Without one, those flows are absent from the type |
|
|
86
86
|
| `session.lifespan` | `Duration` | `'7d'` | How long a session lives |
|
|
87
87
|
| `session.renewAfter` | `Duration \| false` | `'1d'` | When `authenticate` slides the session. `false` for a fixed lifespan |
|
|
88
88
|
| `schemaVersion` | string | `'1'` | Recorded on every user written. Bump it when the schema tightens |
|
|
@@ -95,6 +95,7 @@ that cuts an emoji in half, say.
|
|
|
95
95
|
| `tokens.verifyEmail` | `Duration` | `'24h'` | How long a verification token lives |
|
|
96
96
|
| `tokens.resetPassword` | `Duration` | `'1h'` | How long a reset token lives |
|
|
97
97
|
| `tokens.signInCode` | `Duration` | `'10m'` | How long an e-mailed sign-in code and its challenge live. See [sign-in codes](sign-in-code.md) |
|
|
98
|
+
| `tokens.magicLink` | `Duration` | `'15m'` | How long an e-mailed sign-in link lives. See [sign-in links](magic-link.md) |
|
|
98
99
|
| `tokens.stepUp` | `Duration` | `'10m'` | How long a step-up's challenge — and its e-mailed code — lives. See [step-up](step-up.md) |
|
|
99
100
|
| `signIn.throttle` | `{ attempts?, window? }` or `false` | `{ attempts: 10, window: '15m' }` | Passwords one login may try per window, the right one included, before `signIn` answers `CREDENTIALS_INVALID` with `retryAfter` until the window ends. `false` counts nothing. See [passwords](passwords.md#password-guessing-is-throttled) |
|
|
100
101
|
| `secondFactor` | `{ issuer, keys, challenge? }` | none | A TOTP second factor for every type with a password. Changes what `signIn` answers — see [the second factor](second-factor.md#configuration) |
|
|
@@ -221,11 +222,14 @@ await auth.delete(renamed); // true
|
|
|
221
222
|
The patch is spread over the stored fields and the result is checked against
|
|
222
223
|
the schema, so a patch can never leave a user the schema would refuse.
|
|
223
224
|
Changing the e-mail sets `emailVerified` back to `false`, and moves the login
|
|
224
|
-
with it when the e-mail is the login
|
|
225
|
-
[
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
225
|
+
with it when the e-mail is the login — so the next sign-in by code or link
|
|
226
|
+
proves it anew, and [drops the password and the second factor, and signs out
|
|
227
|
+
every session](magic-link.md#an-account-someone-else-registered). It also
|
|
228
|
+
sends a [`user.emailChanged`
|
|
229
|
+
event](events.md#telling-the-user-their-password-or-e-mail-changed) carrying
|
|
230
|
+
`formerEmail`, the address before — so the notice goes to the inbox the
|
|
231
|
+
account just left. A change of case only (`Ada@…` for `ada@…`) is no change,
|
|
232
|
+
and sends nothing.
|
|
229
233
|
|
|
230
234
|
### `ifVersion`
|
|
231
235
|
|
package/docs/guide/vocabulary.md
CHANGED
|
@@ -43,21 +43,22 @@ either finds this row.
|
|
|
43
43
|
| **credential** | What a caller presents to prove who they are: a login and a password at sign-in (`CredentialError`, `CREDENTIALS_INVALID`), or a token on a request — always written *session credential* | |
|
|
44
44
|
| **password policy** | The rules a new password must meet: `minLength`, `normalize` | the model |
|
|
45
45
|
| **session** | A signed-in period, carried by a **session token** | |
|
|
46
|
-
| **lapsed**, **revoked**, **renewed** | A session past its `expiresAt`; one ended by a sign-out or a
|
|
46
|
+
| **lapsed**, **revoked**, **renewed** | A session past its `expiresAt`; one ended by a sign-out, a password reset, or the first proof of an e-mail by a code or a link; one whose `expiresAt` moved in passing (`renewAfter`) | "expired" — kept for `TOKEN_EXPIRED`, a one-time token |
|
|
47
47
|
| **anonymous** | A request that presents no session credential, or one that authenticates nobody: `authenticate` answers `null` | "unauthenticated", "guest", "logged out" |
|
|
48
48
|
| **bearer client** | A client that sends its session token as `Authorization: Bearer` rather than in a cookie | |
|
|
49
|
-
| **one-time token** | A single-use token sent by e-mail in a link, for verification
|
|
49
|
+
| **one-time token** | A single-use token sent by e-mail in a link, for verification, a password reset or a sign-in | "code": a code is typed, a token is followed in a link |
|
|
50
|
+
| **sign-in link** | A one-time token sent by e-mail in a link that signs the user in, with no password — what `magicLink.request` issues and `magicLink.confirm` takes, see [sign-in links](magic-link.md). *Magic link* is its common name, and its API's; in prose, *sign-in link*, or *link* where the sentence says which | "magic code", "login link", "passwordless link" |
|
|
50
51
|
| **one-time code** | Six digits a user types, checked against a challenge. Two kinds: a **TOTP code**, computed by an authenticator app from the user's secret and never sent — what `secondFactor.activate`, `secondFactor.regenerateRecoveryCodes` and `secondFactor.confirm` take, see [the second factor](second-factor.md); and a **sign-in code**, sent by e-mail — what `signInCode.request` issues and `signInCode.confirm` takes, see [sign-in codes](sign-in-code.md). **"Code" alone always means a one-time code**, the kind named by the sentence around it. An error's is written `error.code`, or by its value (`CODE_INVALID`), and said *error code*; the program is *your code* | "OTP", "PIN", "passcode", "magic code"; "code" for an error's `code`, unless written *error code* |
|
|
51
52
|
| **second factor** | What a user proves at sign-in beyond their password: a TOTP secret their authenticator app holds, `UserRecord.secondFactor`. **Enrolled** once `enroll` stored it, waiting for a first code (`confirmedAt: null`); **active** once `activate` accepted one — `hasSecondFactor`, and only then does `signIn` ask for a code | "2FA", "MFA", "OTP device"; "confirmed" for the state — to *confirm* is to redeem a challenge |
|
|
52
53
|
| **recovery code** | One of ten single-use codes `secondFactor.activate` and `secondFactor.regenerateRecoveryCodes` answer **once** — ten characters, shown `xxxxx-xxxxx` — that `secondFactor.recover` takes instead of a TOTP code, for a user whose authenticator app is gone. Stored only as keyed hashes. Always written in full: *code* alone is a one-time code, and a recovery code is never one | "backup code", "scratch code", "recovery key" |
|
|
53
54
|
| **attempt** | One code tried against a challenge, counted by `countAttempt` in the token's `attempts` before the code is checked. A challenge takes five, a second factor's or a sign-in code's | "try"; "retry" is a repeated write, never a guess |
|
|
54
55
|
| **challenge** | A secret the visitor keeps, redeemed once with a code — which is what *confirm* means for it. `signIn` answers one instead of a session when the user's second factor is active, for `secondFactor.confirm` — or `secondFactor.recover`, with a recovery code; `signInCode.request` answers one beside the sign-in code, for `signInCode.confirm`; `stepUp.request` answers one for `stepUp.confirm`. Stored as its hash, as a one-time token of kind `secondFactor`, `signInCode` or `stepUp`: five minutes or ten by default, and five attempts | "MFA token", "pending session", "ticket" |
|
|
55
|
-
| **spent** | A one-time token or a challenge that can no longer be redeemed — `spentAt` set, `TOKEN_SPENT`, and never unset. Spent by its redemption, by its fifth attempt (a wrong code, or a call through another user type's API), by a redemption after it expired, by a refusal that ends it (`USER_INACTIVE`, `TOKEN_STALE`, `SECOND_FACTOR_NOT_ENROLLED`), or by what ends it early: the next `signInCode.request`, `stepUp.request` or `resetPassword.request` for the same user (at most one of each is live at a time), or a password written by `resetPassword.confirm`, `setPassword` or `changePassword` (every reset link and every second-factor challenge left waiting) | "used up", "invalidated"; "consumed" only in `consumeToken`'s name; "revoked" is a session's |
|
|
56
|
+
| **spent** | A one-time token or a challenge that can no longer be redeemed — `spentAt` set, `TOKEN_SPENT`, and never unset. Spent by its redemption, by its fifth attempt (a wrong code, or a call through another user type's API), by a redemption after it expired, by a refusal that ends it (`USER_INACTIVE`, `TOKEN_STALE`, `SECOND_FACTOR_NOT_ENROLLED`), or by what ends it early: the next `signInCode.request`, `magicLink.request`, `stepUp.request` or `resetPassword.request` for the same user (at most one of each is live at a time), or a password written by `resetPassword.confirm`, `setPassword` or `changePassword` (every reset link and every second-factor challenge left waiting) | "used up", "invalidated"; "consumed" only in `consumeToken`'s name; "revoked" is a session's |
|
|
56
57
|
| **step** | The thirty-second period a TOTP code belongs to. `lastStep` is the step of the last code accepted, and only a later step counts: that is why a code is accepted once | "window", "period", "counter", in prose |
|
|
57
58
|
| **seal**, **sealing key** | To seal is to encrypt a TOTP secret — AES-256-GCM, bound to the user's id — before a store sees it. A sealing key is one `{ id, key }` of `secondFactor.keys`: the first seals, every one opens | "encrypt", "encryption key", "master key", "pepper" |
|
|
58
59
|
| **token** | Never alone in prose: a *session token* or a *one-time token*. The `tokens` store and the `TOKEN_*` codes are one-time tokens only | |
|
|
59
|
-
| **e-mail flow** | `verifyEmail` or `resetPassword`: send a one-time token, then confirm it. `signInCode` sends a one-time code instead | |
|
|
60
|
-
| **issued** | What a flow answers for the application to send: an `IssuedToken` from `verifyEmail.send` or `
|
|
60
|
+
| **e-mail flow** | `verifyEmail` or `resetPassword`: send a one-time token, then confirm it. `magicLink` sends one too, to sign in; `signInCode` sends a one-time code instead | |
|
|
61
|
+
| **issued** | What a flow answers for the application to send: an `IssuedToken` from `verifyEmail.send`, `resetPassword.request` or `magicLink.request`, an `IssuedCode` from `signInCode.request` — the address with it. `@nxgt/janus-mail` takes it as it is | "result", "payload" |
|
|
61
62
|
| **notice** | An e-mail that tells a user of a change and asks for nothing: *password changed*, *e-mail changed* — the latter sent to the former address — and *two-factor enabled*, *two-factor disabled*. `@nxgt/janus-mail` sends all four | "alert", "notification" |
|
|
62
63
|
| **user event** | What happened to a user, once it is written: `user.created`, `user.emailVerified`, `user.passwordReset`, `user.passwordChanged`, `user.emailChanged`, `user.secondFactorEnabled`, `user.secondFactorDisabled`, `user.recoveryCodesRegenerated`, `user.recoveryCodeUsed`, `user.deleted` — a `UserEvent`, naming the user by id alone — `user.emailChanged` adds `formerEmail` — with an `id` of its own. See [user events](events.md) | "webhook" — a webhook is one way to deliver it; "event" alone where it could be read as `@nxgt/janus-telemetry`'s audit log record — on a page about user events, "the event" is fine |
|
|
63
64
|
| **step-up**, **fresh** | A step-up is a signed-in user proving again who they are before a sensitive action — `stepUp.request`, then `stepUp.confirm`, with an e-mailed code or, when their second factor is active, their app's — which moves the session's `authenticatedAt` to now and opens no session. A session is **fresh** for `maxAge` after it proved who it is, at the sign-in or by a step-up; `assertFresh` refuses an older one with `STEP_UP_REQUIRED`. See [step-up](step-up.md) | "re-authentication", "sudo mode", "elevated session"; "recent sign-in" once a step-up counts too |
|
package/docs/roadmap.md
CHANGED
|
@@ -70,6 +70,24 @@ Nothing yet.
|
|
|
70
70
|
The last ten, newest first, each with the version it came in. Everything
|
|
71
71
|
before is in the [CHANGELOG](../CHANGELOG.md).
|
|
72
72
|
|
|
73
|
+
- **The first sign-in by link or code ends a squatter's password and second
|
|
74
|
+
factor, v0.16.0** — when `signInCode.confirm` or `magicLink.confirm`
|
|
75
|
+
proves an e-mail never verified, the password and the second factor with
|
|
76
|
+
its recovery codes are dropped and every session signed out before the
|
|
77
|
+
new one opens: whoever registered the address without its inbox keeps
|
|
78
|
+
nothing, and the owner is asked for no factor they never set.
|
|
79
|
+
`user.passwordChanged` and `user.secondFactorDisabled` report what was
|
|
80
|
+
dropped. An e-mail already verified changes nothing.
|
|
81
|
+
- **Sign in with an e-mailed link (magic link), v0.15.0** —
|
|
82
|
+
`auth.magicLink.request(email)` and `magicLink.confirm(token)` on every
|
|
83
|
+
user type with an e-mail, with or without a password: the sign-in code
|
|
84
|
+
with nothing to type. `request` answers `null` for nobody and for an
|
|
85
|
+
inactive user alike; only the last link sent works; a link lives fifteen
|
|
86
|
+
minutes (`tokens.magicLink`) and is spent by its first `confirm`, which
|
|
87
|
+
proves the e-mail and still asks for an active second factor. Confirmed
|
|
88
|
+
from a `POST`, so a mail scanner that opens the link spends nothing.
|
|
89
|
+
**Breaking for an adapter: `TokenKind` gains `'magicLink'`**, which a
|
|
90
|
+
`CHECK` or a validator's enum must list.
|
|
73
91
|
- **Password guessing throttled per login, v0.14.0** — past ten passwords
|
|
74
92
|
tried at one login in a 15-minute window, `signIn` answers
|
|
75
93
|
`CREDENTIALS_INVALID` with `reason: 'throttled'` and `retryAfter`, the right
|
|
@@ -124,13 +142,6 @@ before is in the [CHANGELOG](../CHANGELOG.md).
|
|
|
124
142
|
`SecondFactorRecord.recoveryCodes`,
|
|
125
143
|
stored by the users stores of `@nxgt/janus-drizzle` and
|
|
126
144
|
`@nxgt/janus-mongo`, with conformance cases.
|
|
127
|
-
- **The second factor in the user events, v0.9.0** —
|
|
128
|
-
`user.secondFactorEnabled`, once `secondFactor.activate` made a factor
|
|
129
|
-
active, and `user.secondFactorDisabled`, once `secondFactor.disable`
|
|
130
|
-
removed an active one: a listener can tell the user, as
|
|
131
|
-
`@nxgt/janus-mail`'s `twoFactorEnabled` and `twoFactorDisabled` notices
|
|
132
|
-
do, whoever made the change. `enroll`, a factor still waiting, and a
|
|
133
|
-
`disable` that found none send nothing.
|
|
134
145
|
- **Sending the e-mails, `@nxgt/janus-mail` v0.1.0** — a package of its
|
|
135
146
|
own, built on the `@nxgt/mail` toolkit: `janusMail({ mailer, from, brand,
|
|
136
147
|
links })` takes what each flow answers —
|
|
@@ -149,8 +160,3 @@ before is in the [CHANGELOG](../CHANGELOG.md).
|
|
|
149
160
|
`STORE_FAILED` on PostgreSQL alone. The relation store suite holds every
|
|
150
161
|
adapter to reading back every other character in an id exactly as written.
|
|
151
162
|
— v0.8.3
|
|
152
|
-
- **Webhooks, `@nxgt/janus-webhooks` v0.1.0** — a package of its own: the
|
|
153
|
-
user events `janus({ events })` hands over, signed by the Standard
|
|
154
|
-
Webhooks specification (HMAC-SHA256, secrets that rotate) and posted to
|
|
155
|
-
your endpoints, retried with backoff, and reported to `onGivingUp` when
|
|
156
|
-
given up — never dropped in silence. `verifyWebhook` is the receiving side.
|
package/docs/troubleshooting.md
CHANGED
|
@@ -91,6 +91,15 @@ How the messages are shaped:
|
|
|
91
91
|
- [`TS2339: Property 'signInCode' does not exist on type 'TypeApi<…>'.`](#ts2339-property-signincode-does-not-exist-on-type-typeapi)
|
|
92
92
|
- `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.
|
|
93
93
|
|
|
94
|
+
**Sign-in links**
|
|
95
|
+
- [A sign-in link answers `TOKEN_SPENT` on the user's first click](#a-sign-in-link-answers-token_spent-on-the-users-first-click)
|
|
96
|
+
- [The link's own button answers `403`](#the-links-own-button-answers-403)
|
|
97
|
+
- [The link signed in another device than the one that asked](#the-link-signed-in-another-device-than-the-one-that-asked)
|
|
98
|
+
- [`magicLink.request` answers `null` for a user who exists](#magiclinkrequest-answers-null-for-a-user-who-exists)
|
|
99
|
+
- [`TS2339: Property 'magicLink' does not exist on type 'TypeApi<…>'.`](#ts2339-property-magiclink-does-not-exist-on-type-typeapi)
|
|
100
|
+
- [`TS2554: Expected 1 arguments, but got 2.`, on `magicLink.confirm`](#ts2554-expected-1-arguments-but-got-2-on-magiclinkconfirm)
|
|
101
|
+
- `TOKEN_UNKNOWN`, `TOKEN_SPENT`, `TOKEN_EXPIRED`, `TOKEN_STALE`, `USER_INACTIVE` and `VERSION_CONFLICT` from `magicLink.confirm`, and `TS2339` on its `token`: in their entries above.
|
|
102
|
+
|
|
94
103
|
**Step-up**
|
|
95
104
|
- [`STEP_UP_REQUIRED` — `assertFresh: the session proved who it is longer ago than maxAge …`](#step_up_required--assertfresh-the-session-proved-who-it-is-longer-ago-than-maxage--confirm-with-a-step-up)
|
|
96
105
|
- [`TOKEN_UNKNOWN` — `stepUp.confirm: no such challenge`, with a challenge just issued](#token_unknown--stepupconfirm-no-such-challenge-with-a-challenge-just-issued)
|
|
@@ -132,6 +141,7 @@ How the messages are shaped:
|
|
|
132
141
|
- [`findUser should answer recoveryCodes [] as [] — never null, never undefined`](#finduser-should-answer-recoverycodes--as---never-null-never-undefined)
|
|
133
142
|
- [`reauthenticateSession racing revokeSession: the session should stay revoked — one conditional write, never a read then a write`](#reauthenticatesession-racing-revokesession-the-session-should-stay-revoked--one-conditional-write-never-a-read-then-a-write)
|
|
134
143
|
- [`tokens.insertToken: the store could not answer`, in `tokens.everyKind`](#tokensinserttoken-the-store-could-not-answer-in-tokenseverykind)
|
|
144
|
+
- [`consumeToken for a signInCode token redeemed as magicLink`, in `tokens.magicLinkKind`](#consumetoken-for-a-signincode-token-redeemed-as-magiclink-in-tokensmagiclinkkind)
|
|
135
145
|
|
|
136
146
|
---
|
|
137
147
|
|
|
@@ -331,7 +341,7 @@ Also `janus: "<name>" cannot name a user type — janus() answers a method of th
|
|
|
331
341
|
|
|
332
342
|
### `janus: session.lifespan: "<value>" is not a duration; write a number followed by ms, s, m, h or d — for example "15m" or "720h"`
|
|
333
343
|
|
|
334
|
-
The same for `session.renewAfter`, `tokens.verifyEmail`, `tokens.resetPassword`, `tokens.signInCode`, `tokens.stepUp`, `secondFactor.challenge` and `signIn.throttle.window` — and, at call time rather than in `janus()`, `assertFresh: maxAge: "<value>" is not a duration; …`, from a `maxAge` written wrong. Also `<option>: a duration must be above zero` and `<option>: a duration in milliseconds must be a finite number above zero`.
|
|
344
|
+
The same for `session.renewAfter`, `tokens.verifyEmail`, `tokens.resetPassword`, `tokens.signInCode`, `tokens.magicLink`, `tokens.stepUp`, `secondFactor.challenge` and `signIn.throttle.window` — and, at call time rather than in `janus()`, `assertFresh: maxAge: "<value>" is not a duration; …`, from a `maxAge` written wrong. Also `<option>: a duration must be above zero` and `<option>: a duration in milliseconds must be a finite number above zero`.
|
|
335
345
|
|
|
336
346
|
**When:** `janus({...})`.
|
|
337
347
|
**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.
|
|
@@ -507,6 +517,17 @@ challenge — the same code will not, it was used.
|
|
|
507
517
|
form.addEventListener('submit', () => form.querySelector('button')?.setAttribute('disabled', ''));
|
|
508
518
|
```
|
|
509
519
|
|
|
520
|
+
**From `signInCode.confirm` and `magicLink.confirm`**, the message names the
|
|
521
|
+
call: `magicLink.confirm: expected version <n>, found <m>`.
|
|
522
|
+
|
|
523
|
+
**When:** the user was written — an `update`, a sign-in that rehashed the
|
|
524
|
+
password — between `confirm` reading them and writing the e-mail as
|
|
525
|
+
verified. Rare: the window is one read.
|
|
526
|
+
**Why:** the proof is written under the version read, so an address changed
|
|
527
|
+
meanwhile is never the one marked verified. Nothing was written and no
|
|
528
|
+
session opened, but the code's challenge or the link is already spent.
|
|
529
|
+
**Fix:** answer it as a spent link or code: ask for a new one.
|
|
530
|
+
|
|
510
531
|
**From `secondFactor.recover`**, the same store message.
|
|
511
532
|
|
|
512
533
|
**When:** one recovery code used by two sign-ins at once — the same
|
|
@@ -544,8 +565,8 @@ if (error instanceof UserInvalidError) {
|
|
|
544
565
|
Also `changePassword: the current password does not match`.
|
|
545
566
|
|
|
546
567
|
**When:** `signIn`, `changePassword`.
|
|
547
|
-
**Why:** no user holds the login, the user has no password, or the password is wrong — **one code for the three**. Also a sign-in that verified a password written over while it ran (`reason: 'wrongPassword'`): its session is revoked, or its challenge spent, before the refusal. `error.reason` (`unknownLogin`, `noPassword`, `wrongPassword`) tells them apart for your logs and your rate limiter; a login past its attempts has its own message, below. A login holding a NUL character or a lone surrogate is `unknownLogin`: no user can hold one.
|
|
548
|
-
**Fix:** answer 401 with the same body whatever the reason:
|
|
568
|
+
**Why:** no user holds the login, the user has no password, or the password is wrong — **one code for the three**. Also a sign-in that verified a password written over while it ran (`reason: 'wrongPassword'`): its session is revoked, or its challenge spent, before the refusal. `error.reason` (`unknownLogin`, `noPassword`, `wrongPassword`) tells them apart for your logs and your rate limiter; a login past its attempts has its own message, below. A login holding a NUL character or a lone surrogate is `unknownLogin`: no user can hold one. A user who signed up with a password and then signed in by `signInCode` or `magicLink` before verifying the e-mail has no password any more (`noPassword`): that first proof drops it, and the second factor, and signs out every session, so an address registered by someone else keeps nothing of theirs — [the sign-in link guide](guide/magic-link.md#an-account-someone-else-registered) has why.
|
|
569
|
+
**Fix:** answer 401 with the same body whatever the reason — and, for a user who signed in by code or link before verifying, offer `setPassword` after that sign-in, or send `verifyEmail` at sign-up:
|
|
549
570
|
|
|
550
571
|
```ts
|
|
551
572
|
// Never: { reason: error.reason } — `unknownLogin` tells an attacker which users exist.
|
|
@@ -611,8 +632,8 @@ janus({ ..., hasher: scryptHasher(), verifiers: [bcryptVerifier] }); // a Passwo
|
|
|
611
632
|
|
|
612
633
|
### `USER_INACTIVE` — `<call>: the user is inactive`
|
|
613
634
|
|
|
614
|
-
**When:** `signIn`, with the **right** password, for a user set inactive. Also `secondFactor.confirm` and `secondFactor.recover`, for a user set inactive after `signIn` asked for a code,
|
|
615
|
-
**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.
|
|
635
|
+
**When:** `signIn`, with the **right** password, for a user set inactive. Also `secondFactor.confirm` and `secondFactor.recover`, for a user set inactive after `signIn` asked for a code, `signInCode.confirm`, for a user set inactive after the code was sent, and `magicLink.confirm`, for one set inactive after the link was sent.
|
|
636
|
+
**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. `magicLink.confirm` checks after spending the link, which is spent too, and `magicLink.request` answers `null` for an inactive user.
|
|
616
637
|
**Fix:** answer 403, or reactivate, then sign in again: `await auth.setActive(user, true)`.
|
|
617
638
|
|
|
618
639
|
### `TOKEN_UNKNOWN`, `TOKEN_SPENT`, `TOKEN_EXPIRED`
|
|
@@ -686,11 +707,31 @@ more time:
|
|
|
686
707
|
janus({ ..., tokens: { signInCode: '15m' } });
|
|
687
708
|
```
|
|
688
709
|
|
|
710
|
+
**On `magicLink.confirm`**, the messages name the token, as for the other
|
|
711
|
+
links: `magicLink.confirm: no such token`, `magicLink.confirm: the token was
|
|
712
|
+
already used`, `magicLink.confirm: the token has expired`.
|
|
713
|
+
|
|
714
|
+
**When:** `magicLink.confirm(token)`.
|
|
715
|
+
**Why:** a sign-in link lives fifteen minutes and is spent by its first
|
|
716
|
+
`confirm`, whether that call signs in or is refused — so a second click, a
|
|
717
|
+
refresh that posts again, or a link [a mail scanner opened](#a-sign-in-link-answers-token_spent-on-the-users-first-click)
|
|
718
|
+
first answers `TOKEN_SPENT`. The next `request` for the same user spends it
|
|
719
|
+
too: only the last link sent works. `TOKEN_UNKNOWN` also covers a link
|
|
720
|
+
whose user was deleted, one confirmed through another user type's
|
|
721
|
+
`magicLink` — which spends it — a sign-in code's challenge, and a token cut
|
|
722
|
+
short by the mail client wrapping the URL.
|
|
723
|
+
**Fix:** send the visitor back to ask for a new link — every `TOKEN_*`
|
|
724
|
+
means that to them — and tell them to use the latest e-mail.
|
|
725
|
+
|
|
726
|
+
```ts
|
|
727
|
+
janus({ ..., tokens: { magicLink: '30m' } });
|
|
728
|
+
```
|
|
729
|
+
|
|
689
730
|
### `TOKEN_STALE` — `<call>: the token was sent to an e-mail the user no longer has`
|
|
690
731
|
|
|
691
|
-
**When:** `verifyEmail.confirm` or `
|
|
732
|
+
**When:** `verifyEmail.confirm`, `resetPassword.confirm` or `magicLink.confirm`, after the user changed their e-mail — even while the link was being redeemed: the address is checked again on the very record the write replaces.
|
|
692
733
|
**Why:** confirming it would verify an address nobody holds any more, or reset a password through one. The token is spent, and nothing is written.
|
|
693
|
-
**Fix:** send a new token to the current address: `await auth.verifyEmail.send(user)
|
|
734
|
+
**Fix:** send a new token to the current address: `await auth.verifyEmail.send(user)` — or, for a sign-in link, ask for a new one: `magicLink.request` sends it to the current address.
|
|
694
735
|
|
|
695
736
|
### `INVALID_CURSOR` — `<call>: this cursor was not minted by this store, or was minted for another ordering (<n> characters)`
|
|
696
737
|
|
|
@@ -758,7 +799,7 @@ each of those entries has a paragraph for it.
|
|
|
758
799
|
|
|
759
800
|
The same for `session` and `user`.
|
|
760
801
|
|
|
761
|
-
**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.
|
|
802
|
+
**When:** `tsc`, wherever `signIn`'s answer is read, once `janus()` is given a `secondFactor` — and `signInCode.confirm`'s and `magicLink.confirm`'s, on a user type with a password.
|
|
762
803
|
**Why:** `signIn` then answers one of two shapes: `{ status: 'signedIn', user, session, token }`, or `{ status: 'secondFactor', challenge, expiresAt, userId }` for a user whose second factor is active — the password alone opens no session for them. Without `secondFactor`, `signIn` still answers a session.
|
|
763
804
|
**Fix:** switch on `status`:
|
|
764
805
|
|
|
@@ -946,7 +987,7 @@ const { recoveryCodes } = await auth.secondFactor.regenerateRecoveryCodes(curren
|
|
|
946
987
|
|
|
947
988
|
`SecondFactorError`. Also `secondFactor.confirm: the user no longer has a second factor — sign in again`, and the same from `secondFactor.recover`.
|
|
948
989
|
|
|
949
|
-
**When:** `activate` before `enroll`, or after `disable
|
|
990
|
+
**When:** `activate` before `enroll`, or after `disable` — or after a sign-in by `signInCode` or `magicLink` that proved the e-mail for the first time, which removes a factor enrolled before it. On `confirm` or `recover`: the factor was disabled after `signIn` asked for a code, or removed by such a first proof when an outage kept its challenges from being spent — the proof spends them otherwise, and `confirm` answers `TOKEN_SPENT`.
|
|
950
991
|
**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.
|
|
951
992
|
**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.
|
|
952
993
|
|
|
@@ -1151,6 +1192,112 @@ janus({
|
|
|
1151
1192
|
|
|
1152
1193
|
---
|
|
1153
1194
|
|
|
1195
|
+
## Sign-in links
|
|
1196
|
+
|
|
1197
|
+
`magicLink.confirm`'s messages start with `magicLink.confirm` — prefixed by
|
|
1198
|
+
the type with several user types: `patient.magicLink.confirm: …`.
|
|
1199
|
+
`magicLink.request` throws nothing but `STORE_FAILED`: an e-mail it cannot
|
|
1200
|
+
sign in is `null`. `magicLink.confirm` rejects with
|
|
1201
|
+
[`TOKEN_UNKNOWN`, `TOKEN_SPENT`, `TOKEN_EXPIRED`](#token_unknown-token_spent-token_expired),
|
|
1202
|
+
[`TOKEN_STALE`](#token_stale--call-the-token-was-sent-to-an-e-mail-the-user-no-longer-has),
|
|
1203
|
+
[`USER_INACTIVE`](#user_inactive--call-the-user-is-inactive) and
|
|
1204
|
+
[`VERSION_CONFLICT`](#version_conflict--call-expected-version-n-found-m);
|
|
1205
|
+
each of those entries has a paragraph for it. On a type with a password in
|
|
1206
|
+
an instance given a `secondFactor`, reading its `token` before narrowing on
|
|
1207
|
+
`status` is the `TS2339` of `signIn`'s. [The sign-in link guide](guide/magic-link.md)
|
|
1208
|
+
has the whole flow.
|
|
1209
|
+
|
|
1210
|
+
### A sign-in link answers `TOKEN_SPENT` on the user's first click
|
|
1211
|
+
|
|
1212
|
+
`magicLink.confirm: the token was already used`, when the user swears they
|
|
1213
|
+
clicked once.
|
|
1214
|
+
|
|
1215
|
+
**When:** a route calls `magicLink.confirm` on the `GET` of the link — and
|
|
1216
|
+
most often for users behind a corporate mail gateway, or of a webmail that
|
|
1217
|
+
checks links.
|
|
1218
|
+
**Why:** a mail scanner opened the link before the user did. Gateways,
|
|
1219
|
+
safe-browsing checks and antivirus fetch every URL in an e-mail, and some
|
|
1220
|
+
run the page's scripts; a `GET` that confirms spent the token for them.
|
|
1221
|
+
**Fix:** make the link's `GET` a page that spends nothing, and confirm from
|
|
1222
|
+
its button's `POST` — not from a script that submits by itself, which a
|
|
1223
|
+
scanner that runs scripts submits too:
|
|
1224
|
+
|
|
1225
|
+
```ts
|
|
1226
|
+
// GET /sign-in/link?token=… → a page whose <form method="post"> holds the token, and a button
|
|
1227
|
+
// POST /sign-in/link → the one place that calls confirm
|
|
1228
|
+
const token = String((await request.formData()).get('token') ?? '');
|
|
1229
|
+
const signedIn = await auth.magicLink.confirm(token);
|
|
1230
|
+
```
|
|
1231
|
+
|
|
1232
|
+
The [guide](guide/magic-link.md#confirming-from-a-post-never-from-the-links-get)
|
|
1233
|
+
has the page, with the headers it needs.
|
|
1234
|
+
|
|
1235
|
+
### The link's own button answers `403`
|
|
1236
|
+
|
|
1237
|
+
**When:** the page the e-mailed link opens posts its token, and the route
|
|
1238
|
+
that checks the `Origin` before `magicLink.confirm` refuses it — for every
|
|
1239
|
+
user, or only on plain HTTP off `localhost` and in older browsers.
|
|
1240
|
+
**Why:** the page is sent with `Referrer-Policy: no-referrer`, and under it
|
|
1241
|
+
the browser posts the form with `Origin: null`. A check on `Origin` alone
|
|
1242
|
+
refuses every such `POST`; one that tries `Sec-Fetch-Site: same-origin`
|
|
1243
|
+
first refuses it wherever the browser sends no `Sec-Fetch-Site`. A
|
|
1244
|
+
mismatched `ORIGIN` — `http` for `https`, a port, a trailing `/` — does the
|
|
1245
|
+
same.
|
|
1246
|
+
**Fix:** send the page with `Referrer-Policy: strict-origin`, and set
|
|
1247
|
+
`ORIGIN` to the scheme, host and port the address bar shows. A `Referer`
|
|
1248
|
+
then holds the origin only, never the path and its token, and the form
|
|
1249
|
+
carries its real `Origin`:
|
|
1250
|
+
|
|
1251
|
+
```ts
|
|
1252
|
+
new Response(html, { headers: { 'Referrer-Policy': 'strict-origin', 'Cache-Control': 'no-store' } }); // the GET page
|
|
1253
|
+
if (request.headers.get('Origin') !== ORIGIN) return new Response(null, { status: 403 }); // on the POST
|
|
1254
|
+
```
|
|
1255
|
+
|
|
1256
|
+
The [guide](guide/magic-link.md#confirming-from-a-post-never-from-the-links-get)
|
|
1257
|
+
has the page and the route.
|
|
1258
|
+
|
|
1259
|
+
### The link signed in another device than the one that asked
|
|
1260
|
+
|
|
1261
|
+
**When:** a user asks for a link on a laptop and opens the e-mail on their
|
|
1262
|
+
phone: the phone is signed in, the laptop still waits.
|
|
1263
|
+
**Why:** the session opens in the browser that posts the token — whoever
|
|
1264
|
+
opens the link signs in, wherever they open it. That is what a link is.
|
|
1265
|
+
**Fix:** say on the page that asked "open the link on this device", or send
|
|
1266
|
+
a [sign-in code](guide/sign-in-code.md) instead: it is typed into the page
|
|
1267
|
+
that asked for it, so the sign-in completes there.
|
|
1268
|
+
|
|
1269
|
+
### `magicLink.request` answers `null` for a user who exists
|
|
1270
|
+
|
|
1271
|
+
**When:** `magicLink.request(email)`, for an address you can see in the
|
|
1272
|
+
database.
|
|
1273
|
+
**Why:** the same three causes as a
|
|
1274
|
+
[sign-in code's `null`](#signincoderequest-answers-null-for-a-user-who-exists):
|
|
1275
|
+
the user is inactive, is of another user type, or the address is not the
|
|
1276
|
+
type's e-mail field.
|
|
1277
|
+
**Fix:** as there. Do not tell the visitor which — the route answers the
|
|
1278
|
+
same page either way.
|
|
1279
|
+
|
|
1280
|
+
### `TS2339: Property 'magicLink' does not exist on type 'TypeApi<…>'.`
|
|
1281
|
+
|
|
1282
|
+
Also `Property 'magicLink' does not exist on type 'Janus<…>'.`, with the
|
|
1283
|
+
single-type form.
|
|
1284
|
+
|
|
1285
|
+
**When:** `tsc`, on `auth.magicLink` or `clinic.<type>.magicLink`.
|
|
1286
|
+
**Why:** the user type has no e-mail, so a link has nowhere to be sent — as
|
|
1287
|
+
for [`signInCode`](#ts2339-property-signincode-does-not-exist-on-type-typeapi).
|
|
1288
|
+
Or `@nxgt/janus` is older than 0.15, which brought `magicLink`.
|
|
1289
|
+
**Fix:** name the field that holds the e-mail with the type's `email`
|
|
1290
|
+
option, or upgrade `@nxgt/janus`.
|
|
1291
|
+
|
|
1292
|
+
### `TS2554: Expected 1 arguments, but got 2.`, on `magicLink.confirm`
|
|
1293
|
+
|
|
1294
|
+
**When:** `tsc`, on `magicLink.confirm(challenge, code)` — a sign-in code's
|
|
1295
|
+
route copied for a link.
|
|
1296
|
+
**Why:** a link carries no code: its token is the whole secret.
|
|
1297
|
+
**Fix:** `magicLink.confirm(token)`, with the token the link's page posted.
|
|
1298
|
+
|
|
1299
|
+
---
|
|
1300
|
+
|
|
1154
1301
|
## Step-up
|
|
1155
1302
|
|
|
1156
1303
|
The messages start with `stepUp.request` or `stepUp.confirm` — prefixed by
|
|
@@ -1313,7 +1460,7 @@ process.on('warning', (warning) => {
|
|
|
1313
1460
|
|
|
1314
1461
|
**Fix:** for the last two, reconcile against the users themselves and treat events as the fast path, not the record: page through `auth.list()` and compare with the receiver's copy — a user it lacks is a missed `user.created`, a user the receiver has that `auth.find` answers `null` for is a missed `user.deleted`, a user whose `emailVerified` differs is a missed `user.emailVerified`, and a user whose `hasSecondFactor` differs is a missed `user.secondFactorEnabled` or `user.secondFactorDisabled`.
|
|
1315
1462
|
|
|
1316
|
-
A store outage **after** the write does not lose the event. `create
|
|
1463
|
+
A store outage **after** the write does not lose the event. `create` and `signUp` send it before the steps that follow; `delete`, `resetPassword.confirm`, and `signInCode.confirm` and `magicLink.confirm` on an e-mail never verified run theirs — removing the sessions, tokens and tuples; revoking the sessions — in a `try`, and send it from its `finally`, whether they succeeded or not. `signInCode.confirm` and `magicLink.confirm` also revoke the sessions **before** the write: an outage there leaves nothing written and nothing sent, and the next code or link proves the e-mail again.
|
|
1317
1464
|
|
|
1318
1465
|
---
|
|
1319
1466
|
|
|
@@ -1600,5 +1747,24 @@ In SQL, `update … set authenticated_at = $2 where id = $1 and revoked_at is nu
|
|
|
1600
1747
|
### `tokens.insertToken: the store could not answer`, in `tokens.everyKind`
|
|
1601
1748
|
|
|
1602
1749
|
**When:** the case `tokens.everyKind`, on an adapter whose database lists the token kinds — a `CHECK`, a validator's enum.
|
|
1603
|
-
**Why:** the list does not hold `stepUp`, which came in `@nxgt/janus` 0.12, so the database refuses the insert, and the adapter reports it as the outage it cannot tell apart.
|
|
1604
|
-
**Fix:** add
|
|
1750
|
+
**Why:** the list does not hold `stepUp`, which came in `@nxgt/janus` 0.12, or `magicLink`, which came in 0.15, so the database refuses the insert, and the adapter reports it as the outage it cannot tell apart.
|
|
1751
|
+
**Fix:** add the missing kind to the list, and ship the migration or the validator with it: `'verifyEmail', 'resetPassword', 'secondFactor', 'signInCode', 'magicLink', 'stepUp'`.
|
|
1752
|
+
|
|
1753
|
+
### `consumeToken for a signInCode token redeemed as magicLink`, in `tokens.magicLinkKind`
|
|
1754
|
+
|
|
1755
|
+
Also `countAttempt for a magicLink token counted as signInCode`, `spendUserTokens of magicLink: the link only`, and `spendUserTokens of magicLink should not touch a sign-in code, nor anything else have counted it`.
|
|
1756
|
+
|
|
1757
|
+
**When:** the case `tokens.magicLinkKind`, on an adapter that matches a token by its hash alone, or spends a user's tokens of every kind at once.
|
|
1758
|
+
**Why:** a sign-in code's challenge is handed to whoever asked for the code. Redeemed as a link, it would sign them in with no code at all — the kind is what keeps the two apart, so every token method must match it.
|
|
1759
|
+
**Fix:** put `kind` in the condition of `consumeToken`, `countAttempt` and `spendUserTokens`, beside the hash or the user:
|
|
1760
|
+
|
|
1761
|
+
```ts
|
|
1762
|
+
// MongoDB: consumeToken — the hash and the kind, never the hash alone
|
|
1763
|
+
await tokens.findOneAndUpdate(
|
|
1764
|
+
{ _id: tokenHash, kind },
|
|
1765
|
+
[{ $set: { spentAt: { $ifNull: ['$spentAt', at] } } }],
|
|
1766
|
+
{ returnDocument: 'before' },
|
|
1767
|
+
);
|
|
1768
|
+
```
|
|
1769
|
+
|
|
1770
|
+
In SQL, `… where token_hash = $1 and kind = $2`; `spendUserTokens` holds `user_id = $1 and kind = $2`. See [`TokenStore`](guide/adapters.md#sessionstore-and-tokenstore).
|