@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
package/docs/README.md
CHANGED
|
@@ -14,6 +14,7 @@ below are defined once, in [Words](guide/vocabulary.md#words).
|
|
|
14
14
|
| [Users](guide/users.md) | You are wiring `janus()`, declaring one or several user types, or calling `create`, `update`, `list`, `delete` |
|
|
15
15
|
| [Sessions](guide/sessions.md) | You need to know who a request belongs to, set or clear the cookie, renew, sign out, or test expiry |
|
|
16
16
|
| [E-mail verification and password reset](guide/email-flows.md) | You are sending a verification or reset link, and handling what comes back |
|
|
17
|
+
| [Signing in with an e-mailed link](guide/magic-link.md) | You are signing users in with a link sent by e-mail — a magic link: the page that confirms it from a `POST` so mail scanners spend nothing, the lifetime, the errors, and what ends a link |
|
|
17
18
|
| [Signing in with an e-mailed code](guide/sign-in-code.md) | You are signing users in with a six-digit code sent by e-mail — with no password, or beside one: requesting it without telling who exists, keeping the challenge, the attempts and the errors |
|
|
18
19
|
| [The second factor](guide/second-factor.md) | You are turning on TOTP codes: making and rotating the sealing keys, the QR code, `signIn`'s `status`, confirming a challenge, recovery codes for a lost phone, disabling |
|
|
19
20
|
| [Step-up](guide/step-up.md) | A sensitive action — changing the e-mail, disabling the second factor, deleting the account — should ask a signed-in user to prove again who they are: `assertFresh`, `stepUp.request` and `confirm`, by e-mailed code or by their app |
|
|
@@ -32,6 +33,6 @@ below are defined once, in [Words](guide/vocabulary.md#words).
|
|
|
32
33
|
| --- | --- |
|
|
33
34
|
| [The shared vocabulary](guide/vocabulary.md) | You want the words the documentation uses, or subjects and the tuple notation, ids, cursors, durations or `fixedClock` |
|
|
34
35
|
| [Errors](guide/errors.md) | You are turning what the package throws into a status code, and want every code and what it carries |
|
|
35
|
-
| [Writing an adapter](guide/adapters.md) | You are implementing the identity stores or the relation store for your database, upgrading one for `countAttempt`, `spendUserTokens
|
|
36
|
+
| [Writing an adapter](guide/adapters.md) | You are implementing the identity stores or the relation store for your database, upgrading one for `countAttempt`, `spendUserTokens`, the second factor and a new token kind, or running the conformance suites |
|
|
36
37
|
| [Troubleshooting](troubleshooting.md) | You have an error message and want its cause and its fix |
|
|
37
38
|
| [Roadmap](roadmap.md) | You want to know what is coming, what shipped, and what is deliberately not planned |
|
package/docs/guide/adapters.md
CHANGED
|
@@ -160,7 +160,7 @@ update. A read followed by a write lets two requests redeem one reset token.
|
|
|
160
160
|
A token is its hash, never its secret, and what it is for:
|
|
161
161
|
|
|
162
162
|
```ts
|
|
163
|
-
type TokenKind = 'verifyEmail' | 'resetPassword' | 'secondFactor' | 'signInCode' | 'stepUp';
|
|
163
|
+
type TokenKind = 'verifyEmail' | 'resetPassword' | 'secondFactor' | 'signInCode' | 'magicLink' | 'stepUp';
|
|
164
164
|
|
|
165
165
|
interface TokenRecord {
|
|
166
166
|
readonly tokenHash: string;
|
|
@@ -178,9 +178,13 @@ interface TokenRecord {
|
|
|
178
178
|
A token redeemed for another kind is unknown: every method that takes a
|
|
179
179
|
`kind` matches on it. A `stepUp` token is a signed-in user's confirmation of
|
|
180
180
|
a sensitive action; kept apart from `signInCode`, a sign-in code never
|
|
181
|
-
confirms an action, nor an action's code signs anyone in. A
|
|
182
|
-
|
|
183
|
-
|
|
181
|
+
confirms an action, nor an action's code signs anyone in. A `magicLink`
|
|
182
|
+
token is a sign-in link's, new in 0.15, kept apart from `signInCode` the same
|
|
183
|
+
way: a sign-in code's challenge is handed to whoever asked for it, and a
|
|
184
|
+
store that redeemed it as a link would sign them in with no code —
|
|
185
|
+
`tokens.magicLinkKind` holds that it does not. A store that lists the
|
|
186
|
+
kinds — a `CHECK`, an enum in a validator — lists `stepUp` and `magicLink`
|
|
187
|
+
too, which `tokens.everyKind` proves. A `secondFactor` token is the challenge `signIn`
|
|
184
188
|
answers — or the count of a user's attempts at `regenerateRecoveryCodes`,
|
|
185
189
|
a token of the same kind whose secret nobody is given, never redeemed and
|
|
186
190
|
only counted — and its `address` is `''`: a column or a validator that refuses an
|
|
@@ -282,8 +286,8 @@ driver's error in `StoreFailure` as in [the six rules](#the-six-rules):
|
|
|
282
286
|
Spends every **unspent** token of one user and one `kind` at `at` — but the
|
|
283
287
|
one whose hash is `except`, when given — and answers how many it spent. The
|
|
284
288
|
core calls it right after issuing a sign-in code, with that code's hash as
|
|
285
|
-
`except`, so only the last code sent works — and so after issuing a
|
|
286
|
-
or a reset link; and after writing a password, for the user's
|
|
289
|
+
`except`, so only the last code sent works — and so after issuing a sign-in
|
|
290
|
+
link, a step-up or a reset link; and after writing a password, for the user's
|
|
287
291
|
`resetPassword` links and `secondFactor` challenges:
|
|
288
292
|
|
|
289
293
|
| The stored token | Written | Counted |
|
|
@@ -403,7 +407,7 @@ compile error naming the missing method.
|
|
|
403
407
|
|
|
404
408
|
| Suite | Cases | Harness opens |
|
|
405
409
|
| --- | --- | --- |
|
|
406
|
-
| `describeJanusStores({ name, harness, runner?, faults?, skip? })` |
|
|
410
|
+
| `describeJanusStores({ name, harness, runner?, faults?, skip? })` | 55: users, sessions, tokens, and one outage per method whose honest answer can be "nothing" — fourteen of them | `{ stores, faults?, close? }` |
|
|
407
411
|
| `describeRelationStores({ name, harness, runner?, faults?, skip? })` | 16: the relation store, ids of edge characters, and one outage per method | `{ store, faults?, close? }` |
|
|
408
412
|
|
|
409
413
|
`harness.open()` is called **once per case** and must answer fresh, empty
|
|
@@ -34,11 +34,13 @@ verified.emailVerified; // true
|
|
|
34
34
|
`sendMail` above is yours to write, or
|
|
35
35
|
[`@nxgt/janus-mail`](https://www.npmjs.com/package/@nxgt/janus-mail)
|
|
36
36
|
writes it: it takes what `send` and `request` answer, as they answer it, and
|
|
37
|
-
sends the verification, reset and sign-in
|
|
37
|
+
sends the verification, reset, sign-in code and sign-in link e-mails — with the notices
|
|
38
38
|
of a password, an e-mail or a second factor changed, and a welcome on
|
|
39
39
|
`user.created` — in English and French, over any `@nxgt/mail` transport.
|
|
40
|
-
Every link is required: `getStarted`, since janus-mail 0.4.0, is
|
|
41
|
-
welcome's **Get started** button leads
|
|
40
|
+
Every link is required but two: `getStarted`, since janus-mail 0.4.0, is
|
|
41
|
+
where the welcome's **Get started** button leads; `recoveryCodes` and
|
|
42
|
+
`magicLink` — the page a [sign-in link](magic-link.md) opens, since
|
|
43
|
+
janus-mail 0.7.0 — are optional.
|
|
42
44
|
|
|
43
45
|
```ts
|
|
44
46
|
import { janusMail } from '@nxgt/janus-mail';
|
|
@@ -91,8 +93,9 @@ clinic.patient.verifyEmail.send; // exists: `email` names the field
|
|
|
91
93
|
clinic.staff.verifyEmail;
|
|
92
94
|
```
|
|
93
95
|
|
|
94
|
-
A type with an e-mail can also sign in with a code sent to it,
|
|
95
|
-
without a password — see [sign-in codes](sign-in-code.md)
|
|
96
|
+
A type with an e-mail can also sign in with a code or a link sent to it,
|
|
97
|
+
with or without a password — see [sign-in codes](sign-in-code.md) and
|
|
98
|
+
[sign-in links](magic-link.md).
|
|
96
99
|
|
|
97
100
|
## Options
|
|
98
101
|
|
|
@@ -119,7 +122,9 @@ e-mail changed while the link is being redeemed is `TOKEN_STALE` too, and
|
|
|
119
122
|
nothing is written.
|
|
120
123
|
A confirm that verifies the e-mail sends a [`user.emailVerified`
|
|
121
124
|
event](events.md); one for an e-mail already verified sends nothing.
|
|
122
|
-
Changing the e-mail with `update` sets `emailVerified` back to `false
|
|
125
|
+
Changing the e-mail with `update` sets `emailVerified` back to `false` — and
|
|
126
|
+
the next sign-in by code or link then [drops the password and the second
|
|
127
|
+
factor, and signs out every session](magic-link.md#an-account-someone-else-registered).
|
|
123
128
|
|
|
124
129
|
## `resetPassword`
|
|
125
130
|
|
|
@@ -180,8 +185,8 @@ The links are spent **after** the password is written, so a link issued
|
|
|
180
185
|
during the write is spent too. If the store fails at that step, the call
|
|
181
186
|
rejects with `STORE_FAILED` although the password is written — answer 503 as
|
|
182
187
|
usual — and the older links may still be live until the next `request`,
|
|
183
|
-
which spends them. Sign-in codes and step-ups are not spent:
|
|
184
|
-
proves
|
|
188
|
+
which spends them. Sign-in codes, sign-in links and step-ups are not spent:
|
|
189
|
+
the password proves none of them.
|
|
185
190
|
|
|
186
191
|
`confirm` sets the password, marks the e-mail verified — the link proved it —
|
|
187
192
|
and **signs the user out everywhere**: their sessions are revoked, and every
|
|
@@ -234,6 +239,7 @@ never the token, and no refusal's message contains it.
|
|
|
234
239
|
|
|
235
240
|
- [Users](users.md) — `email`, `update`, and the other per-type methods
|
|
236
241
|
- [Sign-in codes](sign-in-code.md) — the third flow that sends an e-mail: a code, not a link
|
|
242
|
+
- [Sign-in links](magic-link.md) — the fourth: a link that signs in, confirmed from a `POST` so a mail scanner spends nothing
|
|
237
243
|
- [Sessions](sessions.md) — `signOutEverywhere`, which `resetPassword.confirm` calls for you
|
|
238
244
|
- [User events](events.md) — `user.emailVerified` and `user.passwordReset`, which the confirms send
|
|
239
245
|
- [Errors](errors.md) — every code, and the status it deserves
|
package/docs/guide/errors.md
CHANGED
|
@@ -56,9 +56,9 @@ that wired the library, so no handler needs to tell it apart.
|
|
|
56
56
|
| `PASSWORD_TOO_SHORT` | `CredentialError` | 400 | Below `password.minLength` | `minLength` — never the password |
|
|
57
57
|
| `CREDENTIALS_INVALID` | `CredentialError` | 401 | Unknown login, no password, or the wrong one — **one code for the three** — and any password, the right one included, at a login past its attempts in the window — see [throttling](passwords.md#password-guessing-is-throttled) | `reason`, for your logs only; `retryAfter`, the seconds until the next window, when throttled |
|
|
58
58
|
| `HASH_UNSUPPORTED` | `CredentialError` | 400 | A stored hash no wired hasher reads | `hashPrefix` — never the hash |
|
|
59
|
-
| `USER_INACTIVE` | `UserInactiveError` | 403 | Deactivated; told only to someone who gave the right password,
|
|
59
|
+
| `USER_INACTIVE` | `UserInactiveError` | 403 | Deactivated; told only to someone who gave the right password, the right code, or a live sign-in link | `userId` |
|
|
60
60
|
| `STEP_UP_REQUIRED` | `StepUpRequiredError` | 403 | `assertFresh`: the session proved who it is `maxAge` ago or more. Not a denial of the action — ask for a [step-up](step-up.md), then send the request again | `userId` |
|
|
61
|
-
| `TOKEN_UNKNOWN`, `TOKEN_SPENT`, `TOKEN_EXPIRED`, `TOKEN_STALE` | `TokenError` | 400 | See [e-mail flows](email-flows.md#what-a-token-refusal-means). For a second factor's challenge: sign in again; for a sign-in code's: request a new code — see [sign-in codes](sign-in-code.md#what-confirm-refuses) | `userId` on `TOKEN_STALE` |
|
|
61
|
+
| `TOKEN_UNKNOWN`, `TOKEN_SPENT`, `TOKEN_EXPIRED`, `TOKEN_STALE` | `TokenError` | 400 | See [e-mail flows](email-flows.md#what-a-token-refusal-means). For a second factor's challenge: sign in again; for a sign-in code's: request a new code — see [sign-in codes](sign-in-code.md#what-confirm-refuses); for a sign-in link: ask for a new link — see [sign-in links](magic-link.md#what-confirm-refuses) | `userId` on `TOKEN_STALE` |
|
|
62
62
|
| `CODE_INVALID` | `TokenError` | 401 | A one-time code that does not match: a second factor's, or one already accepted — see [the second factor](second-factor.md#confirming-the-code-at-sign-in) — a recovery code not the user's, or already used — see [recovery codes](second-factor.md#signing-in-with-one) — or a sign-in code sent by e-mail — see [sign-in codes](sign-in-code.md#attempts) | `attemptsLeft` from either `confirm` and from `recover`: what the challenge has left, `0` once it is spent; from `regenerateRecoveryCodes`, what the user's 15-minute window has left, `0` past the fifth — see [regenerating them](second-factor.md#regenerating-them). None from `activate`. `userId` |
|
|
63
63
|
| `SECOND_FACTOR_NOT_ENROLLED` | `SecondFactorError` | 409 | `activate` before `enroll`; `regenerateRecoveryCodes` without an active factor; `confirm` or `recover` after the factor was disabled | `userId` |
|
|
64
64
|
| `SECOND_FACTOR_ACTIVE` | `SecondFactorError` | 409 | `enroll` or `activate` on a factor already active: `disable` it first | `userId` |
|
package/docs/guide/events.md
CHANGED
|
@@ -43,12 +43,12 @@ The words — **user event**, **listener** — are defined in
|
|
|
43
43
|
| `type` | Sent by | Not sent |
|
|
44
44
|
| --- | --- | --- |
|
|
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
|
-
| `user.emailVerified` | `verifyEmail.confirm`; `resetPassword.confirm`, whose link proves the e-mail; `signInCode.confirm`, whose code does | for an e-mail already verified, or a refused confirm |
|
|
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` | 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`
|
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
# Signing in with an e-mailed link
|
|
2
|
+
|
|
3
|
+
This page is for signing a user in with a link sent to their e-mail — a
|
|
4
|
+
"magic link": no password, nothing to type, and the link proves the address.
|
|
5
|
+
`janus` issues and redeems the link's token; **building the link, sending the
|
|
6
|
+
e-mail and the page it opens are yours**.
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
import { z } from 'zod';
|
|
10
|
+
import { createMemoryStores, janus, scryptHasher } from '@nxgt/janus';
|
|
11
|
+
|
|
12
|
+
async function sendMail(to: string, subject: string, text: string): Promise<void> {
|
|
13
|
+
// your mailer
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const auth = janus({
|
|
17
|
+
user: z.object({ email: z.email() }),
|
|
18
|
+
password: { login: 'email' },
|
|
19
|
+
store: createMemoryStores(),
|
|
20
|
+
hasher: scryptHasher(),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const issued = await auth.magicLink.request('ada@example.com'); // { token, email, expiresAt, user } | null
|
|
24
|
+
if (issued !== null) {
|
|
25
|
+
const link = `https://app.example/sign-in/link?token=${issued.token}`;
|
|
26
|
+
await sendMail(issued.email, 'Your sign-in link', `${link}\nIt expires in 15 minutes.`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// the link opens a page of yours, whose button POSTs the token back:
|
|
30
|
+
const signedIn = await auth.magicLink.confirm(token);
|
|
31
|
+
signedIn.token; // a session, as signIn opens one
|
|
32
|
+
signedIn.user.emailVerified; // true: the link reached the inbox
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
It is the [sign-in code](sign-in-code.md) with the code folded into a link:
|
|
36
|
+
the same users have it, the same refusals, the same second factor. What
|
|
37
|
+
differs is what a link is — something anybody holding the e-mail can open,
|
|
38
|
+
on any device, **and that a mail scanner opens too**. The words — **sign-in
|
|
39
|
+
link**, **one-time token**, **spent**, **issued** — are defined in
|
|
40
|
+
[the vocabulary](vocabulary.md#identities).
|
|
41
|
+
|
|
42
|
+
## Which types have it
|
|
43
|
+
|
|
44
|
+
`magicLink` exists on every user type with an e-mail — the field `email`
|
|
45
|
+
names, or a required string field called `email` — **with or without a
|
|
46
|
+
password**, exactly as `signInCode` does. A type with no e-mail has no
|
|
47
|
+
`magicLink`: it is absent from its type, not failing at run time.
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
clinic.patient.magicLink.request; // exists
|
|
51
|
+
clinic.member.magicLink.request; // exists: a passwordless type signs in by link or code
|
|
52
|
+
// @ts-expect-error — staff have no e-mail to send a link to
|
|
53
|
+
clinic.staff.magicLink;
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Requesting a link
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
const issued = await auth.magicLink.request(email);
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`request` looks up the user of this type holding that e-mail — trimmed and
|
|
63
|
+
lowercased first — and issues a token for them:
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
interface IssuedToken {
|
|
67
|
+
readonly token: string; // 32 random bytes, base64url: for the link, and nowhere else
|
|
68
|
+
readonly email: string; // the address to send it to, as the user's field holds it
|
|
69
|
+
readonly expiresAt: Date; // fifteen minutes from now, by default
|
|
70
|
+
}
|
|
71
|
+
// request answers IssuedToken & { readonly user: U } — or null
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
It answers **`null`** when nobody of this type holds that e-mail, when the
|
|
75
|
+
user is inactive, and when the value matches a login that only looks like an
|
|
76
|
+
e-mail. Nothing is issued, nothing is written.
|
|
77
|
+
|
|
78
|
+
**Never tell the visitor which.** Answer the same page, with the same
|
|
79
|
+
headers — "if an account uses that address, we sent it a link". Unlike a
|
|
80
|
+
sign-in code, there is **no decoy to forge**: nothing of a link reaches the
|
|
81
|
+
visitor, so the same `202` with no body and no cookie is already the same
|
|
82
|
+
answer. Send the e-mail **after** answering, from a queue, so the time the
|
|
83
|
+
answer takes does not tell either; the store's one write for a link issued,
|
|
84
|
+
and none for `null`, still tells a patient observer.
|
|
85
|
+
|
|
86
|
+
**At most one link is live per user.** A `request` issues its token, then
|
|
87
|
+
spends every other link of the user, so the link in an earlier e-mail
|
|
88
|
+
answers `TOKEN_SPENT` — only the last one works. Requests that arrive at
|
|
89
|
+
once cannot each keep a link: at most one survives, sometimes none, and the
|
|
90
|
+
visitor asks again. A link and a [sign-in code](sign-in-code.md) are
|
|
91
|
+
separate kinds: asking for one leaves the other live.
|
|
92
|
+
|
|
93
|
+
**Rate-limit the request route**, per address and per client, as you would a
|
|
94
|
+
password reset: every call sends an e-mail, and cancels the link before it.
|
|
95
|
+
Without a limit, anyone who knows an address can fill its inbox.
|
|
96
|
+
|
|
97
|
+
## Building the link
|
|
98
|
+
|
|
99
|
+
The token is base64url — safe in a URL without escaping — and goes in a
|
|
100
|
+
link to **a page of yours**, never to the route that signs in:
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
const link = `https://app.example/sign-in/link?token=${issued.token}`;
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Put the token **in the e-mail and nowhere else**: not in a log, not in an
|
|
107
|
+
analytics event. A query string reaches your server's access log, so leave
|
|
108
|
+
the query out of the log line on that path. Only the token's hash is
|
|
109
|
+
stored, like a session token's, so the store cannot give it back.
|
|
110
|
+
|
|
111
|
+
Send it to `issued.email`, not to what the visitor typed: it is the address
|
|
112
|
+
the user's field holds, as they registered it.
|
|
113
|
+
|
|
114
|
+
[`@nxgt/janus-mail`](https://www.npmjs.com/package/@nxgt/janus-mail)
|
|
115
|
+
sends this e-mail ready-made, in English and French, over any `@nxgt/mail`
|
|
116
|
+
transport: its **Sign in** button is built by `links.magicLink(token)` —
|
|
117
|
+
point it at the page below — and it says how long the link lasts, from
|
|
118
|
+
`issued.expiresAt`.
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
const mail = janusMail({ …, links: { …, magicLink: (token) => `https://app.example/sign-in/link?token=${token}` } });
|
|
122
|
+
if (issued !== null) await mail.magicLink(issued, { locale: 'fr-CA' }); // the recipient's locale: sent in fr
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
`links.magicLink` is optional there, and `mail.magicLink` throws a
|
|
126
|
+
`TypeError` without it. Its send rejects with the mailer's `MailFailure` when
|
|
127
|
+
the transport fails: hand it to a queue that awaits it and retries, rather
|
|
128
|
+
than a promise nobody awaits.
|
|
129
|
+
|
|
130
|
+
## Confirming from a `POST`, never from the link's `GET`
|
|
131
|
+
|
|
132
|
+
**Mail scanners open links.** A corporate gateway, a webmail's safe-browsing
|
|
133
|
+
check and an antivirus fetch every URL in an e-mail before the user sees
|
|
134
|
+
it, and some run its scripts. A route that signs in on `GET` would spend the
|
|
135
|
+
token for the scanner: the user clicks, and gets `TOKEN_SPENT`.
|
|
136
|
+
|
|
137
|
+
So the link lands on a **page that spends nothing**, and the user's click
|
|
138
|
+
on a button there sends the `POST` that calls `confirm`:
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
const TOKEN = /^[A-Za-z0-9_-]{43}$/;
|
|
142
|
+
|
|
143
|
+
// GET /sign-in/link?token=… — a page, and nothing else
|
|
144
|
+
export function linkPage(request: Request): Response {
|
|
145
|
+
const token = new URL(request.url).searchParams.get('token') ?? '';
|
|
146
|
+
if (!TOKEN.test(token)) return new Response('This link is not valid.', { status: 400 });
|
|
147
|
+
return new Response(
|
|
148
|
+
`<!doctype html><meta charset="utf-8"><title>Sign in</title>
|
|
149
|
+
<form method="post" action="/sign-in/link">
|
|
150
|
+
<input type="hidden" name="token" value="${token}">
|
|
151
|
+
<button>Sign in</button>
|
|
152
|
+
</form>`,
|
|
153
|
+
{
|
|
154
|
+
headers: {
|
|
155
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
156
|
+
'Referrer-Policy': 'strict-origin', // the origin only: the token's URL reaches nobody
|
|
157
|
+
'Cache-Control': 'no-store',
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
- **Echo only a token of the token's shape** — 43 base64url characters.
|
|
165
|
+
Anything else written into the page is a cross-site scripting hole.
|
|
166
|
+
- **A button, not a script that submits by itself.** A scanner that runs
|
|
167
|
+
scripts submits the form too; one that does not click buttons does not.
|
|
168
|
+
Auto-submitting saves the user a click, at the price of every link a
|
|
169
|
+
script-running scanner opens.
|
|
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.
|
|
178
|
+
- **Refuse a `POST` from another site.** The token says nothing of the
|
|
179
|
+
browser that asked for it: a page anywhere that posts a token of the
|
|
180
|
+
attacker's own account to your route signs its visitor in as the attacker,
|
|
181
|
+
and what they type next lands there. Check `Origin` against your own, or
|
|
182
|
+
that `Sec-Fetch-Site` is `same-origin`, before calling `confirm` — as the
|
|
183
|
+
route [below](#as-routes) does.
|
|
184
|
+
|
|
185
|
+
## Confirming the link
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
const signedIn = await auth.magicLink.confirm(token);
|
|
189
|
+
// { status: 'signedIn', user, session, token }: the session is open
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
`confirm` spends the token, marks the user's e-mail verified — the link
|
|
193
|
+
reached the inbox — and opens a session: the same `SignedIn` that `signIn`
|
|
194
|
+
answers. **The session opens in the browser that opened the link**, which
|
|
195
|
+
need not be the one that asked for it: a link asked for on a laptop and
|
|
196
|
+
opened on a phone signs the phone in. Where the sign-in must complete in
|
|
197
|
+
the browser that started it, send a [sign-in code](sign-in-code.md) instead.
|
|
198
|
+
|
|
199
|
+
The token is spent **by the first call**, in one conditional write, whether
|
|
200
|
+
that call signs in or is refused: of ten confirmations of one link at once,
|
|
201
|
+
exactly one signs in and nine answer `TOKEN_SPENT`. There is nothing to
|
|
202
|
+
guess — 256 random bits — so no attempts are counted.
|
|
203
|
+
|
|
204
|
+
### Lifetime
|
|
205
|
+
|
|
206
|
+
A link lives **fifteen minutes** unless `tokens.magicLink` says otherwise,
|
|
207
|
+
and is `TOKEN_EXPIRED` after that. Fifteen rather than a code's ten: the
|
|
208
|
+
e-mail must arrive and be opened, and a scanner may hold it a minute first.
|
|
209
|
+
No longer: whoever reads the mailbox in that window signs in.
|
|
210
|
+
|
|
211
|
+
```ts
|
|
212
|
+
janus({ ..., tokens: { magicLink: '30m' } });
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
### The e-mail is verified
|
|
216
|
+
|
|
217
|
+
A user whose `emailVerified` was `false` has it `true` once `confirm`
|
|
218
|
+
succeeds, and their `version` moves; a
|
|
219
|
+
[`user.emailVerified` event](events.md) is sent. A user already verified is
|
|
220
|
+
not written, and nothing is sent. The proof is written under the version
|
|
221
|
+
read: a user written meanwhile answers `VERSION_CONFLICT`, with the link
|
|
222
|
+
spent.
|
|
223
|
+
|
|
224
|
+
### An account someone else registered
|
|
225
|
+
|
|
226
|
+
The proof can land on an account its owner did not create. `signUp` does
|
|
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
|
+
```
|
|
265
|
+
|
|
266
|
+
### A second factor is still asked for
|
|
267
|
+
|
|
268
|
+
The link proves the e-mail, not the second factor. With
|
|
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)
|
|
273
|
+
redeems it with their app's code.
|
|
274
|
+
|
|
275
|
+
```ts
|
|
276
|
+
const result = await auth.magicLink.confirm(token);
|
|
277
|
+
if (result.status === 'secondFactor') {
|
|
278
|
+
// result.challenge: for secondFactor.confirm — keep it as a sign-in keeps it
|
|
279
|
+
} else {
|
|
280
|
+
// result.token, result.session: set the cookie
|
|
281
|
+
}
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
`confirm` answers `SignInResult` on a type whose `signIn` does — a type with
|
|
285
|
+
a password, in an instance given a `secondFactor` — and reading `token`
|
|
286
|
+
before narrowing on `status` is a compile error there. Anywhere else it
|
|
287
|
+
answers `SignedIn`.
|
|
288
|
+
|
|
289
|
+
### What `confirm` refuses
|
|
290
|
+
|
|
291
|
+
| Rejects with | When | What to do |
|
|
292
|
+
| --- | --- | --- |
|
|
293
|
+
| `TOKEN_UNKNOWN` | no such link — a mangled URL, another user type's, a sign-in code's challenge, one whose user was deleted, or one a store's TTL already dropped. Message: `magicLink.confirm: no such token`. Another type's link is spent by the call | ask for a new link |
|
|
294
|
+
| `TOKEN_SPENT` | the link already signed someone in, a newer `request` for the same user spent it, or an earlier `confirm` of it was refused. Message: `magicLink.confirm: the token was already used` | ask for a new link, and use the latest e-mail |
|
|
295
|
+
| `TOKEN_EXPIRED` | `expiresAt` has passed. The link is spent | ask for a new link |
|
|
296
|
+
| `TOKEN_STALE` | the user changed their e-mail since the link was sent. Message: `magicLink.confirm: the token was sent to an e-mail the user no longer has`. The link is spent | ask for a new link, to the current address |
|
|
297
|
+
| `USER_INACTIVE` | the user was deactivated since the link was sent. The link is spent | answer 403 |
|
|
298
|
+
| `VERSION_CONFLICT` | rare: another write to the user landed while `confirm` marked the e-mail verified. The link is spent | ask for a new link |
|
|
299
|
+
|
|
300
|
+
Every refusal is a `TokenError` but `USER_INACTIVE`, a `UserInactiveError`;
|
|
301
|
+
with several user types the message starts with the type:
|
|
302
|
+
`patient.magicLink.confirm: …`. Every call may also reject with
|
|
303
|
+
`STORE_FAILED` — `request` as well as `confirm`: your 503, never "no such
|
|
304
|
+
account" nor a 401. [Troubleshooting](../troubleshooting.md#sign-in-links)
|
|
305
|
+
has each message with its cause.
|
|
306
|
+
|
|
307
|
+
## What does not end a link
|
|
308
|
+
|
|
309
|
+
- **Writing the password.** `resetPassword.confirm`, `changePassword` and
|
|
310
|
+
`setPassword` spend the user's reset links and second-factor challenges,
|
|
311
|
+
not their sign-in links: the password proves nothing a link does, and
|
|
312
|
+
whoever holds the inbox can ask for another link anyway. The same holds
|
|
313
|
+
for sign-in codes.
|
|
314
|
+
- **Signing out everywhere.** It revokes sessions; a link opens a new one.
|
|
315
|
+
- **The sign-in throttle.** `signIn.throttle` counts passwords tried at one
|
|
316
|
+
login; a link is not a password, and its token cannot be guessed. Limit
|
|
317
|
+
`magicLink.request` yourself, as above.
|
|
318
|
+
|
|
319
|
+
What does: its own redemption, a newer `request`, its fifteen minutes, an
|
|
320
|
+
e-mail changed since (`TOKEN_STALE`), the user deactivated
|
|
321
|
+
(`USER_INACTIVE`), and deleting the user, which deletes every token of
|
|
322
|
+
theirs.
|
|
323
|
+
|
|
324
|
+
## Options
|
|
325
|
+
|
|
326
|
+
| Option | Type | Default | Effect |
|
|
327
|
+
| --- | --- | --- | --- |
|
|
328
|
+
| `tokens.magicLink` | `Duration` | `'15m'` | How long a link can be confirmed |
|
|
329
|
+
| `email` | a field name | `'email'` | The field the link is sent to, and looked up by, per type |
|
|
330
|
+
|
|
331
|
+
A `tokens.magicLink` that is not a duration is refused when `janus()` is
|
|
332
|
+
called, with a `TypeError`: `janus: tokens.magicLink: "<value>" is not a
|
|
333
|
+
duration; …`.
|
|
334
|
+
|
|
335
|
+
## As routes
|
|
336
|
+
|
|
337
|
+
The page above, and a fetch-style pair beside it — the shape Bun and most
|
|
338
|
+
frameworks hand you;
|
|
339
|
+
[`@nxgt/janus-hono`](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus-hono/docs/guide/routes.md#a-link-sent-by-e-mail)
|
|
340
|
+
has the same in Hono, behind its `csrf()`:
|
|
341
|
+
|
|
342
|
+
```ts
|
|
343
|
+
import { JanusError, TokenError, UserInactiveError } from '@nxgt/janus';
|
|
344
|
+
|
|
345
|
+
const ORIGIN = 'https://app.example';
|
|
346
|
+
|
|
347
|
+
// POST /sign-in/email/link — ask for a link
|
|
348
|
+
export async function requestLink(request: Request): Promise<Response> {
|
|
349
|
+
const { email } = (await request.json()) as { email: string };
|
|
350
|
+
const issued = await auth.magicLink.request(email);
|
|
351
|
+
if (issued !== null) {
|
|
352
|
+
const link = `${ORIGIN}/sign-in/link?token=${issued.token}`;
|
|
353
|
+
void sendMail(issued.email, 'Your sign-in link', link); // not awaited, so the answer's time tells nothing; in production, a queue that awaits and retries
|
|
354
|
+
}
|
|
355
|
+
return new Response(null, { status: 202 }); // the same answer either way
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// POST /sign-in/link — the page's button
|
|
359
|
+
export async function confirmLink(request: Request): Promise<Response> {
|
|
360
|
+
// Only your own page posts here: another site's form would sign its visitor in.
|
|
361
|
+
if (request.headers.get('Origin') !== ORIGIN) return new Response(null, { status: 403 });
|
|
362
|
+
const token = String((await request.formData()).get('token') ?? '');
|
|
363
|
+
try {
|
|
364
|
+
const signedIn = await auth.magicLink.confirm(token);
|
|
365
|
+
return new Response(null, {
|
|
366
|
+
status: 303,
|
|
367
|
+
headers: { Location: '/', 'Set-Cookie': auth.cookie.serialize(signedIn.token, signedIn.session) },
|
|
368
|
+
});
|
|
369
|
+
} catch (error) {
|
|
370
|
+
if (error instanceof UserInactiveError) return new Response(null, { status: 403 });
|
|
371
|
+
if (error instanceof TokenError || (error instanceof JanusError && error.code === 'VERSION_CONFLICT')) {
|
|
372
|
+
return new Response(null, { status: 303, headers: { Location: '/sign-in?link=expired' } });
|
|
373
|
+
}
|
|
374
|
+
throw error; // STORE_FAILED: your 503
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
A refused link sends the user back to ask for another: every `TOKEN_*`, and
|
|
380
|
+
the rare `VERSION_CONFLICT`, means the same thing to them. With a
|
|
381
|
+
`secondFactor` configured, narrow `confirm`'s answer on `status` before
|
|
382
|
+
reading `token`, and hand the challenge on to
|
|
383
|
+
[the second factor's route](second-factor.md#a-sign-in-with-a-code-as-routes).
|
|
384
|
+
|
|
385
|
+
## In a test
|
|
386
|
+
|
|
387
|
+
No mailbox needed: `request` answers the token it would have sent.
|
|
388
|
+
|
|
389
|
+
```ts
|
|
390
|
+
import { expect, it } from 'bun:test';
|
|
391
|
+
import { z } from 'zod';
|
|
392
|
+
import { createMemoryStores, fixedClock, janus } from '@nxgt/janus';
|
|
393
|
+
|
|
394
|
+
it('signs in with the e-mailed link, once, and not after fifteen minutes', async () => {
|
|
395
|
+
const clock = fixedClock(Date.UTC(2026, 0, 1));
|
|
396
|
+
const auth = janus({ user: z.object({ email: z.email() }), store: createMemoryStores(), clock });
|
|
397
|
+
await auth.create({ email: 'ada@example.com' });
|
|
398
|
+
|
|
399
|
+
const issued = await auth.magicLink.request('ada@example.com');
|
|
400
|
+
if (issued === null) throw new Error('expected a link');
|
|
401
|
+
expect((await auth.magicLink.confirm(issued.token)).user.emailVerified).toBe(true);
|
|
402
|
+
await expect(auth.magicLink.confirm(issued.token)).rejects.toMatchObject({ code: 'TOKEN_SPENT' });
|
|
403
|
+
|
|
404
|
+
const late = await auth.magicLink.request('ada@example.com');
|
|
405
|
+
if (late === null) throw new Error('expected a link');
|
|
406
|
+
clock.advance(15 * 60_000);
|
|
407
|
+
await expect(auth.magicLink.confirm(late.token)).rejects.toMatchObject({ code: 'TOKEN_EXPIRED' });
|
|
408
|
+
});
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
## Signatures
|
|
412
|
+
|
|
413
|
+
```ts
|
|
414
|
+
interface MagicLinkApi<U, Answer = SignedIn<U>> {
|
|
415
|
+
readonly magicLink: {
|
|
416
|
+
request(email: string): Promise<(IssuedToken & { readonly user: U }) | null>;
|
|
417
|
+
confirm(token: string): Promise<Answer>;
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
`Answer` is `SignInResult<U>` on a type with a password in an instance given
|
|
423
|
+
a `secondFactor`, and `SignedIn<U>` everywhere else. `MagicLinkApi` and
|
|
424
|
+
`IssuedToken` are exported from `@nxgt/janus`, as types.
|
|
425
|
+
|
|
426
|
+
## For an adapter
|
|
427
|
+
|
|
428
|
+
A link is a token of kind **`magicLink`**, new in 0.15. A store that lists
|
|
429
|
+
the kinds — a `CHECK`, a validator's enum — lists it too; the conformance
|
|
430
|
+
suite's `tokens.everyKind` fails until it does, and `tokens.magicLinkKind`
|
|
431
|
+
holds that a link and a sign-in code are never redeemed as each other. See
|
|
432
|
+
[adapters](adapters.md#sessionstore-and-tokenstore).
|
|
433
|
+
|
|
434
|
+
## See also
|
|
435
|
+
|
|
436
|
+
- [Sign-in codes](sign-in-code.md) — the same sign-in with a code to type, which completes in the browser that asked
|
|
437
|
+
- [The second factor](second-factor.md) — the challenge `confirm` answers for a user whose factor is active
|
|
438
|
+
- [E-mail verification and password reset](email-flows.md) — the other flows that send a link, and `TOKEN_STALE`
|
|
439
|
+
- [Sessions](sessions.md) — the cookie the session is sent in
|
|
440
|
+
- [Errors](errors.md) — every code and its status
|
|
441
|
+
- [Troubleshooting](../troubleshooting.md#sign-in-links) — by the message you see
|
|
442
|
+
- [`@nxgt/janus-telemetry`](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus-telemetry/docs/guide/tracing.md#a-link-sent-by-e-mail) — the events a sign-in by link writes
|
package/docs/guide/passwords.md
CHANGED
|
@@ -158,8 +158,9 @@ What it does, precisely:
|
|
|
158
158
|
refused while the login is throttled**, so somebody who knows only a login
|
|
159
159
|
can keep its password sign-in shut by trying ten passwords every window.
|
|
160
160
|
That is the price of counting per login; limit per client address (below),
|
|
161
|
-
and a [sign-in code](sign-in-code.md), when you
|
|
162
|
-
the account
|
|
161
|
+
and a [sign-in code](sign-in-code.md) or [link](magic-link.md), when you
|
|
162
|
+
wire them, still opens the account: the throttle counts passwords, never
|
|
163
|
+
e-mailed codes or links.
|
|
163
164
|
- **Per login, known or not.** A login nobody holds is counted as a
|
|
164
165
|
registered one is — `unknownLogin` ten times, then `throttled` — so the
|
|
165
166
|
throttle does not say which logins exist; an unknown login is still
|
|
@@ -232,8 +233,8 @@ The throttle counts per login. It does not see:
|
|
|
232
233
|
the call, with the limiter you already run.
|
|
233
234
|
- `changePassword`, which compares the current password too: limit it per
|
|
234
235
|
user.
|
|
235
|
-
- `resetPassword.request` and `
|
|
236
|
-
them per address.
|
|
236
|
+
- `resetPassword.request`, `signInCode.request` and `magicLink.request`,
|
|
237
|
+
which send e-mail: limit them per address.
|
|
237
238
|
|
|
238
239
|
```ts
|
|
239
240
|
if (!(await limiter.consume(`sign-in:${clientAddress}`))) {
|