@nxgt/janus 0.15.0 → 0.16.1
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 +41 -18
- package/dist/auth/email-sign-in.d.ts +7 -2
- package/dist/auth/email-sign-in.d.ts.map +1 -1
- package/dist/auth/events.d.ts +7 -2
- 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/types/email-flows.d.ts +4 -1
- package/dist/auth/types/email-flows.d.ts.map +1 -1
- package/dist/auth/types/magic-link.d.ts +5 -1
- package/dist/auth/types/magic-link.d.ts.map +1 -1
- package/dist/index.js +138 -108
- package/dist/index.js.map +8 -7
- package/docs/guide/email-flows.md +3 -1
- package/docs/guide/events.md +4 -4
- package/docs/guide/magic-link.md +51 -20
- package/docs/guide/second-factor.md +26 -1
- package/docs/guide/sessions.md +4 -1
- package/docs/guide/sign-in-code.md +9 -4
- package/docs/guide/step-up.md +33 -0
- package/docs/guide/users.md +8 -5
- package/docs/guide/vocabulary.md +1 -1
- package/docs/roadmap.md +8 -7
- package/docs/troubleshooting.md +29 -4
- package/package.json +1 -1
package/docs/guide/events.md
CHANGED
|
@@ -45,10 +45,10 @@ The words — **user event**, **listener** — are defined in
|
|
|
45
45
|
| `user.created` | `create`, `signUp` — once the user is inserted, even if `signUp`'s session then fails to open | for a sign-up refused (`LOGIN_TAKEN`, `USER_INVALID`, `PASSWORD_TOO_SHORT`) |
|
|
46
46
|
| `user.emailVerified` | `verifyEmail.confirm`; `resetPassword.confirm` and `magicLink.confirm`, whose link proves the e-mail; `signInCode.confirm`, whose code does | for an e-mail already verified, or a refused confirm |
|
|
47
47
|
| `user.passwordReset` | `resetPassword.confirm` | for `setPassword` or `changePassword` — they are not resets |
|
|
48
|
-
| `user.passwordChanged` | `changePassword` and `setPassword` — a first password set on a user created without one included — once the password is written — even if spending the reset links and second-factor challenges it ends then fails | for a reset, which is `user.passwordReset` alone; for a `changePassword` refused (`CREDENTIALS_INVALID`) |
|
|
48
|
+
| `user.passwordChanged` | `changePassword` and `setPassword` — a first password set on a user created without one included — once the password is written — even if spending the reset links and second-factor challenges it ends then fails; `signInCode.confirm` and `magicLink.confirm` when their first proof of the e-mail dropped a password, after `user.emailVerified` | for a reset, which is `user.passwordReset` alone; for a `changePassword` refused (`CREDENTIALS_INVALID`) |
|
|
49
49
|
| `user.emailChanged` | `update`, when it changed the e-mail — added, replaced or removed. It carries `formerEmail`, the address before | for an update that leaves the e-mail alone, or changes only its case (`Ada@…` for `ada@…`): compared normalised, the same test that makes the new address unverified; for a type with no e-mail; for an update refused |
|
|
50
50
|
| `user.secondFactorEnabled` | `secondFactor.activate`, once the first code made the factor active | for `enroll`, which leaves the factor waiting and asked for nowhere; for a code refused |
|
|
51
|
-
| `user.secondFactorDisabled` | `secondFactor.disable`, when it removed an **active** factor | for a user who had no factor, a factor still waiting for its first code — it was never asked for — or a second `disable` |
|
|
51
|
+
| `user.secondFactorDisabled` | `secondFactor.disable`, when it removed an **active** factor; `signInCode.confirm` and `magicLink.confirm` when their first proof of the e-mail removed one, after `user.emailVerified` and any `user.passwordChanged` — even if an outage interrupts the sign-outs | for a user who had no factor, a factor still waiting for its first code — it was never asked for — or a second `disable` |
|
|
52
52
|
| `user.recoveryCodesRegenerated` | `secondFactor.regenerateRecoveryCodes`: the codes the user held stopped working | for `activate`, whose first codes come with `user.secondFactorEnabled`; for a code refused |
|
|
53
53
|
| `user.recoveryCodeUsed` | `secondFactor.recover`, once the recovery code is spent — even if opening the session then fails | for a recovery code refused. How many are left is not in the event: `secondFactor.recoveryCodesLeft(event.userId)` reads it |
|
|
54
54
|
| `user.deleted` | `delete`, when it deleted the user | for a replay that finds nobody, or an id of another user type |
|
|
@@ -204,7 +204,7 @@ Where the listener runs within a flow, and what an outage does to it:
|
|
|
204
204
|
| `verifyEmail.confirm` | after the write — the flow's last step | — |
|
|
205
205
|
| `update` | after the write — the flow's last step | — |
|
|
206
206
|
| `changePassword`, `setPassword` | **after** the reset links and the second-factor challenges left open are spent | fails the call; the event is sent all the same, from a `finally` |
|
|
207
|
-
| `signInCode.confirm`, `magicLink.confirm` | after the write
|
|
207
|
+
| `signInCode.confirm`, `magicLink.confirm` | on an e-mail never verified: **after** every session is revoked and the reset links and second-factor challenges left open are spent — before the write and again after it — and **before** the new session or second-factor challenge is opened. `user.passwordChanged` follows `user.emailVerified` when a password was dropped, then `user.secondFactorDisabled` when an active second factor was removed. An e-mail already verified sends nothing | fails the call; the events are sent all the same, from a `finally`. An outage in the sign-outs before the write fails the call with nothing written and nothing sent: the next code or link proves the e-mail again |
|
|
208
208
|
| `secondFactor.activate`, `secondFactor.regenerateRecoveryCodes`, `secondFactor.disable` | after the write — the flow's last step | — |
|
|
209
209
|
| `secondFactor.recover` | after the recovery code is spent and the session opened — or failed to open | fails the call; the recovery code is spent and the event sent all the same, from a `finally` |
|
|
210
210
|
| `resetPassword.confirm` | **after** the sessions opened with the old password are revoked, and the other reset links and the second-factor challenges left open are spent | fails the call; the events are sent all the same, from a `finally` |
|
|
@@ -316,7 +316,7 @@ see [troubleshooting](../troubleshooting.md#janus-events-must-be-a-function-that
|
|
|
316
316
|
## See also
|
|
317
317
|
|
|
318
318
|
- [E-mail verification and password reset](email-flows.md) — the flows that send `user.emailVerified` and `user.passwordReset`
|
|
319
|
-
- [The second factor](second-factor.md) — `activate` and `disable`, which send `user.secondFactorEnabled` and `user.secondFactorDisabled
|
|
319
|
+
- [The second factor](second-factor.md) — `activate` and `disable`, which send `user.secondFactorEnabled` and `user.secondFactorDisabled` — sent too by the first proof of an e-mail by a [code](sign-in-code.md) or a [link](magic-link.md#an-account-someone-else-registered); [recovery codes](second-factor.md#recovery-codes), which send `user.recoveryCodesRegenerated` and `user.recoveryCodeUsed`
|
|
320
320
|
- [Users](users.md) — `create`, `signUp`, `update`, `delete`
|
|
321
321
|
- [Passwords](passwords.md) — `changePassword` and `setPassword`, which send `user.passwordChanged`
|
|
322
322
|
- [Troubleshooting](../troubleshooting.md) — `JANUS_EVENT_FAILED`
|
package/docs/guide/magic-link.md
CHANGED
|
@@ -153,7 +153,7 @@ export function linkPage(request: Request): Response {
|
|
|
153
153
|
{
|
|
154
154
|
headers: {
|
|
155
155
|
'Content-Type': 'text/html; charset=utf-8',
|
|
156
|
-
'Referrer-Policy': '
|
|
156
|
+
'Referrer-Policy': 'strict-origin', // the origin only: the token's URL reaches nobody
|
|
157
157
|
'Cache-Control': 'no-store',
|
|
158
158
|
},
|
|
159
159
|
},
|
|
@@ -167,8 +167,14 @@ export function linkPage(request: Request): Response {
|
|
|
167
167
|
scripts submits the form too; one that does not click buttons does not.
|
|
168
168
|
Auto-submitting saves the user a click, at the price of every link a
|
|
169
169
|
script-running scanner opens.
|
|
170
|
-
- **`Referrer-Policy:
|
|
171
|
-
of the `Referer` of anything the page loads
|
|
170
|
+
- **`Referrer-Policy: strict-origin`** keeps the URL, and the token in it,
|
|
171
|
+
out of the `Referer` of anything the page loads: a `Referer` holds the
|
|
172
|
+
origin only — `https://app.example/` — never the path and its query, and
|
|
173
|
+
none at all from HTTPS to HTTP. Not `no-referrer`: with it the browser
|
|
174
|
+
posts the form with `Origin: null`, which a check on `Origin` alone — as
|
|
175
|
+
`confirmLink` [below](#as-routes) — refuses every time, and one that tries
|
|
176
|
+
`Sec-Fetch-Site` first refuses whenever the browser sends none: on plain
|
|
177
|
+
HTTP off `localhost`, or in an older browser.
|
|
172
178
|
- **Refuse a `POST` from another site.** The token says nothing of the
|
|
173
179
|
browser that asked for it: a page anywhere that posts a token of the
|
|
174
180
|
attacker's own account to your route signs its visitor in as the attacker,
|
|
@@ -218,27 +224,52 @@ spent.
|
|
|
218
224
|
### An account someone else registered
|
|
219
225
|
|
|
220
226
|
The proof can land on an account its owner did not create. `signUp` does
|
|
221
|
-
not wait for the e-mail to be proved,
|
|
222
|
-
|
|
223
|
-
Ada later signs in by link, `confirm`
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
227
|
+
not wait for the e-mail to be proved, so anyone can register
|
|
228
|
+
`ada@example.com` with a password of theirs — and enrol a second factor on
|
|
229
|
+
their own phone. When Ada later signs in by link, `confirm` proves an e-mail
|
|
230
|
+
never verified, and **drops the password and the second factor, and signs
|
|
231
|
+
out every session** before it opens hers — as a
|
|
232
|
+
[password reset](email-flows.md) does. The same holds for a
|
|
233
|
+
[code](sign-in-code.md).
|
|
234
|
+
|
|
235
|
+
- In the write that proves the e-mail, the password is removed
|
|
236
|
+
(`hasPassword` turns `false`), and so is the second factor, active or
|
|
237
|
+
still waiting for its first code, with its recovery codes
|
|
238
|
+
(`hasSecondFactor` turns `false`): Ada's sign-in asks for no factor she
|
|
239
|
+
never set. Every session of the user is revoked, and
|
|
240
|
+
the reset links and second-factor challenges still waiting are spent,
|
|
241
|
+
before that write and again after it: an outage before it leaves the
|
|
242
|
+
e-mail unproved, so the next link or code runs all of it again.
|
|
243
|
+
- [`user.emailVerified`](events.md) is sent, then `user.passwordChanged`
|
|
244
|
+
when a password was dropped, then `user.secondFactorDisabled` when an
|
|
245
|
+
active factor was — even if an outage interrupts the sign-outs.
|
|
246
|
+
- **An e-mail already verified changes nothing**: the password, the second
|
|
247
|
+
factor and the sessions stay, and nothing is written or sent.
|
|
248
|
+
- An e-mail changed by `update` is unverified again: its first proof drops
|
|
249
|
+
the password and the second factor too.
|
|
250
|
+
- **A user who enrolled a factor before proving their own e-mail loses it
|
|
251
|
+
the same way**: the library cannot tell them from a squatter. Send
|
|
252
|
+
`verifyEmail` before offering `secondFactor.enroll`, and no owner's
|
|
253
|
+
factor is ever dropped by a sign-in.
|
|
254
|
+
|
|
255
|
+
A user who signed up with a password and signs in by link before verifying
|
|
256
|
+
has **no password afterwards**: `signIn` answers `CREDENTIALS_INVALID` with
|
|
257
|
+
`reason: 'noPassword'`. Offer to set one:
|
|
258
|
+
|
|
259
|
+
```ts
|
|
260
|
+
const signedIn = await auth.magicLink.confirm(token);
|
|
261
|
+
if (signedIn.status === 'signedIn' && !signedIn.user.hasPassword) {
|
|
262
|
+
// a page of yours that calls auth.setPassword(signedIn.user, password)
|
|
263
|
+
}
|
|
264
|
+
```
|
|
235
265
|
|
|
236
266
|
### A second factor is still asked for
|
|
237
267
|
|
|
238
268
|
The link proves the e-mail, not the second factor. With
|
|
239
|
-
`janus({ secondFactor })`, a user whose
|
|
240
|
-
from the link**: `confirm` answers
|
|
241
|
-
|
|
269
|
+
`janus({ secondFactor })`, a user whose e-mail was already verified and
|
|
270
|
+
whose factor is active gets **no session from the link**: `confirm` answers
|
|
271
|
+
a challenge, exactly as `signIn` does, and
|
|
272
|
+
[`secondFactor.confirm`](second-factor.md#confirming-the-code-at-sign-in)
|
|
242
273
|
redeems it with their app's code.
|
|
243
274
|
|
|
244
275
|
```ts
|
|
@@ -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
|
|
|
@@ -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
|
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
|
|
|
@@ -274,16 +274,21 @@ succeeds, and their `version` moves: the code proves the address as a
|
|
|
274
274
|
verification link would, and a [`user.emailVerified` event](events.md) is
|
|
275
275
|
sent. A user already verified is not written, and nothing is sent.
|
|
276
276
|
|
|
277
|
-
**
|
|
278
|
-
|
|
279
|
-
|
|
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
|
|
280
284
|
[the sign-in link guide](magic-link.md#an-account-someone-else-registered):
|
|
281
285
|
the same holds for a code.
|
|
282
286
|
|
|
283
287
|
### A second factor is still asked for
|
|
284
288
|
|
|
285
289
|
The code proves the e-mail, not the second factor. With `janus({ secondFactor })`,
|
|
286
|
-
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`
|
|
287
292
|
answers a challenge, exactly as `signIn` does with a password, and
|
|
288
293
|
[`secondFactor.confirm`](second-factor.md#confirming-the-code-at-sign-in)
|
|
289
294
|
redeems it with the code of their authenticator app — or
|
package/docs/guide/step-up.md
CHANGED
|
@@ -30,6 +30,12 @@ const session = await auth.stepUp.confirm(request, issued.challenge, code);
|
|
|
30
30
|
session.authenticatedAt; // now: the action goes through for the next ten minutes
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
+
`sendMail` stands for your own sending. With
|
|
34
|
+
[`@nxgt/janus-mail`](https://www.npmjs.com/package/@nxgt/janus-mail) 0.8,
|
|
35
|
+
`mail.stepUp(issued, { name, locale })` sends it — "Your confirmation code",
|
|
36
|
+
in English or French — see [Sending the code with
|
|
37
|
+
`@nxgt/janus-mail`](#sending-the-code-with-nxgtjanus-mail).
|
|
38
|
+
|
|
33
39
|
A step-up is two requests, as a sign-in by code is: one issues the code, the
|
|
34
40
|
other takes it and stamps the session. It **opens no session** and hands out
|
|
35
41
|
no new token: the session the request presents is the one confirmed, which
|
|
@@ -110,6 +116,31 @@ if (issued.via === 'email') {
|
|
|
110
116
|
return Response.json({ challenge: issued.challenge, via: issued.via });
|
|
111
117
|
```
|
|
112
118
|
|
|
119
|
+
### Sending the code with `@nxgt/janus-mail`
|
|
120
|
+
|
|
121
|
+
`@nxgt/janus-mail`'s `mail.stepUp` takes what `request` answered, once
|
|
122
|
+
narrowed to `via: 'email'`, and sends it to `issued.email`: a greeting by
|
|
123
|
+
name, the code, how long it lasts — "This code expires in 10 minutes." —
|
|
124
|
+
and a **Secure my account** button to its `links.secureAccount()`, for a
|
|
125
|
+
user who asked for nothing. It reads the code, the address and `expiresAt`,
|
|
126
|
+
never the challenge:
|
|
127
|
+
|
|
128
|
+
```ts
|
|
129
|
+
import { janusMail } from '@nxgt/janus-mail';
|
|
130
|
+
|
|
131
|
+
const mail = janusMail({ mailer, from: 'noreply@acme.example', brand: 'Acme', links });
|
|
132
|
+
|
|
133
|
+
const issued = await auth.stepUp.request(current.user);
|
|
134
|
+
if (issued.via === 'email') {
|
|
135
|
+
await mail.stepUp(issued, { name: current.user.name, locale: current.user.locale });
|
|
136
|
+
}
|
|
137
|
+
return Response.json({ challenge: issued.challenge, via: issued.via });
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
An un-narrowed answer is a compile error there, and a `via: 'secondFactor'`
|
|
141
|
+
passed from JavaScript a `TypeError`: an app's step-up has nothing to send.
|
|
142
|
+
See its [sending guide](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus-mail/docs/guide/sending.md#stepupissued-to-options).
|
|
143
|
+
|
|
113
144
|
**One step-up is live per user**: a request spends every step-up issued
|
|
114
145
|
before it. The challenge lapses after ten minutes by default —
|
|
115
146
|
`janus({ tokens: { stepUp: '5m' } })` changes it.
|
|
@@ -176,3 +207,5 @@ calls are traced like every other flow.
|
|
|
176
207
|
- [Signing in with an e-mailed code](sign-in-code.md) — the same six digits,
|
|
177
208
|
to sign in.
|
|
178
209
|
- [Errors](errors.md) — every code and its status.
|
|
210
|
+
- [`@nxgt/janus-mail`](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus-mail/docs/guide/sending.md#stepupissued-to-options)
|
|
211
|
+
— `mail.stepUp`, the step-up's e-mail.
|
package/docs/guide/users.md
CHANGED
|
@@ -222,11 +222,14 @@ await auth.delete(renamed); // true
|
|
|
222
222
|
The patch is spread over the stored fields and the result is checked against
|
|
223
223
|
the schema, so a patch can never leave a user the schema would refuse.
|
|
224
224
|
Changing the e-mail sets `emailVerified` back to `false`, and moves the login
|
|
225
|
-
with it when the e-mail is the login
|
|
226
|
-
[
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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.
|
|
230
233
|
|
|
231
234
|
### `ifVersion`
|
|
232
235
|
|
package/docs/guide/vocabulary.md
CHANGED
|
@@ -43,7 +43,7 @@ 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
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 |
|
package/docs/roadmap.md
CHANGED
|
@@ -70,6 +70,14 @@ 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.
|
|
73
81
|
- **Sign in with an e-mailed link (magic link), v0.15.0** —
|
|
74
82
|
`auth.magicLink.request(email)` and `magicLink.confirm(token)` on every
|
|
75
83
|
user type with an e-mail, with or without a password: the sign-in code
|
|
@@ -134,13 +142,6 @@ before is in the [CHANGELOG](../CHANGELOG.md).
|
|
|
134
142
|
`SecondFactorRecord.recoveryCodes`,
|
|
135
143
|
stored by the users stores of `@nxgt/janus-drizzle` and
|
|
136
144
|
`@nxgt/janus-mongo`, with conformance cases.
|
|
137
|
-
- **The second factor in the user events, v0.9.0** —
|
|
138
|
-
`user.secondFactorEnabled`, once `secondFactor.activate` made a factor
|
|
139
|
-
active, and `user.secondFactorDisabled`, once `secondFactor.disable`
|
|
140
|
-
removed an active one: a listener can tell the user, as
|
|
141
|
-
`@nxgt/janus-mail`'s `twoFactorEnabled` and `twoFactorDisabled` notices
|
|
142
|
-
do, whoever made the change. `enroll`, a factor still waiting, and a
|
|
143
|
-
`disable` that found none send nothing.
|
|
144
145
|
- **Sending the e-mails, `@nxgt/janus-mail` v0.1.0** — a package of its
|
|
145
146
|
own, built on the `@nxgt/mail` toolkit: `janusMail({ mailer, from, brand,
|
|
146
147
|
links })` takes what each flow answers —
|
package/docs/troubleshooting.md
CHANGED
|
@@ -93,6 +93,7 @@ How the messages are shaped:
|
|
|
93
93
|
|
|
94
94
|
**Sign-in links**
|
|
95
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)
|
|
96
97
|
- [The link signed in another device than the one that asked](#the-link-signed-in-another-device-than-the-one-that-asked)
|
|
97
98
|
- [`magicLink.request` answers `null` for a user who exists](#magiclinkrequest-answers-null-for-a-user-who-exists)
|
|
98
99
|
- [`TS2339: Property 'magicLink' does not exist on type 'TypeApi<…>'.`](#ts2339-property-magiclink-does-not-exist-on-type-typeapi)
|
|
@@ -564,8 +565,8 @@ if (error instanceof UserInvalidError) {
|
|
|
564
565
|
Also `changePassword: the current password does not match`.
|
|
565
566
|
|
|
566
567
|
**When:** `signIn`, `changePassword`.
|
|
567
|
-
**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.
|
|
568
|
-
**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:
|
|
569
570
|
|
|
570
571
|
```ts
|
|
571
572
|
// Never: { reason: error.reason } — `unknownLogin` tells an attacker which users exist.
|
|
@@ -986,7 +987,7 @@ const { recoveryCodes } = await auth.secondFactor.regenerateRecoveryCodes(curren
|
|
|
986
987
|
|
|
987
988
|
`SecondFactorError`. Also `secondFactor.confirm: the user no longer has a second factor — sign in again`, and the same from `secondFactor.recover`.
|
|
988
989
|
|
|
989
|
-
**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`.
|
|
990
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.
|
|
991
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.
|
|
992
993
|
|
|
@@ -1231,6 +1232,30 @@ const signedIn = await auth.magicLink.confirm(token);
|
|
|
1231
1232
|
The [guide](guide/magic-link.md#confirming-from-a-post-never-from-the-links-get)
|
|
1232
1233
|
has the page, with the headers it needs.
|
|
1233
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
|
+
|
|
1234
1259
|
### The link signed in another device than the one that asked
|
|
1235
1260
|
|
|
1236
1261
|
**When:** a user asks for a link on a laptop and opens the e-mail on their
|
|
@@ -1435,7 +1460,7 @@ process.on('warning', (warning) => {
|
|
|
1435
1460
|
|
|
1436
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`.
|
|
1437
1462
|
|
|
1438
|
-
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.
|
|
1439
1464
|
|
|
1440
1465
|
---
|
|
1441
1466
|
|