@nxgt/janus 0.14.0 → 0.15.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.
Files changed (48) hide show
  1. package/README.md +80 -14
  2. package/dist/auth/config/janus-config.d.ts +2 -0
  3. package/dist/auth/config/janus-config.d.ts.map +1 -1
  4. package/dist/auth/config/resolved-config.d.ts +1 -0
  5. package/dist/auth/config/resolved-config.d.ts.map +1 -1
  6. package/dist/auth/email-sign-in.d.ts +40 -0
  7. package/dist/auth/email-sign-in.d.ts.map +1 -0
  8. package/dist/auth/events.d.ts +3 -2
  9. package/dist/auth/events.d.ts.map +1 -1
  10. package/dist/auth/index.d.ts +1 -1
  11. package/dist/auth/index.d.ts.map +1 -1
  12. package/dist/auth/magic-link.d.ts +22 -0
  13. package/dist/auth/magic-link.d.ts.map +1 -0
  14. package/dist/auth/port/types/tokens.d.ts +6 -3
  15. package/dist/auth/port/types/tokens.d.ts.map +1 -1
  16. package/dist/auth/sign-in-code.d.ts +1 -3
  17. package/dist/auth/sign-in-code.d.ts.map +1 -1
  18. package/dist/auth/types/email-flows.d.ts +3 -3
  19. package/dist/auth/types/index.d.ts +1 -0
  20. package/dist/auth/types/index.d.ts.map +1 -1
  21. package/dist/auth/types/janus.d.ts +8 -1
  22. package/dist/auth/types/janus.d.ts.map +1 -1
  23. package/dist/auth/types/magic-link.d.ts +46 -0
  24. package/dist/auth/types/magic-link.d.ts.map +1 -0
  25. package/dist/auth/users/any-type-api.d.ts +2 -2
  26. package/dist/auth/users/any-type-api.d.ts.map +1 -1
  27. package/dist/auth/users/type-api.d.ts +2 -1
  28. package/dist/auth/users/type-api.d.ts.map +1 -1
  29. package/dist/conformance/cases/tokens/kinds.d.ts +4 -1
  30. package/dist/conformance/cases/tokens/kinds.d.ts.map +1 -1
  31. package/dist/conformance/index.js +23 -1
  32. package/dist/conformance/index.js.map +3 -3
  33. package/dist/index.js +71 -16
  34. package/dist/index.js.map +9 -7
  35. package/docs/README.md +2 -1
  36. package/docs/guide/adapters.md +11 -7
  37. package/docs/guide/email-flows.md +11 -7
  38. package/docs/guide/errors.md +2 -2
  39. package/docs/guide/events.md +2 -2
  40. package/docs/guide/magic-link.md +411 -0
  41. package/docs/guide/passwords.md +5 -4
  42. package/docs/guide/second-factor.md +2 -1
  43. package/docs/guide/sign-in-code.md +14 -6
  44. package/docs/guide/users.md +2 -1
  45. package/docs/guide/vocabulary.md +5 -4
  46. package/docs/roadmap.md +10 -5
  47. package/docs/troubleshooting.md +149 -8
  48. 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` and the second factor, or running the conformance suites |
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 |
@@ -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 store that lists
182
- the kinds — a `CHECK`, an enum in a validator — lists `stepUp` too, which
183
- `tokens.everyKind` proves. A `secondFactor` token is the challenge `signIn`
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 step-up
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? })` | 54: users, sessions, tokens, and one outage per method whose honest answer can be "nothing" — fourteen of them | `{ stores, faults?, close? }` |
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 code e-mails — with the notices
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 where the
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, with or
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
 
@@ -180,8 +183,8 @@ The links are spent **after** the password is written, so a link issued
180
183
  during the write is spent too. If the store fails at that step, the call
181
184
  rejects with `STORE_FAILED` although the password is written — answer 503 as
182
185
  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: the password
184
- proves neither.
186
+ which spends them. Sign-in codes, sign-in links and step-ups are not spent:
187
+ the password proves none of them.
185
188
 
186
189
  `confirm` sets the password, marks the e-mail verified — the link proved it —
187
190
  and **signs the user out everywhere**: their sessions are revoked, and every
@@ -234,6 +237,7 @@ never the token, and no refusal's message contains it.
234
237
 
235
238
  - [Users](users.md) — `email`, `update`, and the other per-type methods
236
239
  - [Sign-in codes](sign-in-code.md) — the third flow that sends an e-mail: a code, not a link
240
+ - [Sign-in links](magic-link.md) — the fourth: a link that signs in, confirmed from a `POST` so a mail scanner spends nothing
237
241
  - [Sessions](sessions.md) — `signOutEverywhere`, which `resetPassword.confirm` calls for you
238
242
  - [User events](events.md) — `user.emailVerified` and `user.passwordReset`, which the confirms send
239
243
  - [Errors](errors.md) — every code, and the status it deserves
@@ -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, or the right code | `userId` |
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` |
@@ -43,7 +43,7 @@ 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
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`) |
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 |
@@ -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, **before** the session or the second-factor challenge is opened | fails the call; the event is already sent |
207
+ | `signInCode.confirm`, `magicLink.confirm` | after the write, **before** the session or the second-factor challenge is opened | fails the call; the event is already sent |
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` |
@@ -0,0 +1,411 @@
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': 'no-referrer', // the URL holds the token: send it nowhere
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: no-referrer`** keeps the URL, and the token in it, out
171
+ of the `Referer` of anything the page loads.
172
+ - **Refuse a `POST` from another site.** The token says nothing of the
173
+ browser that asked for it: a page anywhere that posts a token of the
174
+ attacker's own account to your route signs its visitor in as the attacker,
175
+ and what they type next lands there. Check `Origin` against your own, or
176
+ that `Sec-Fetch-Site` is `same-origin`, before calling `confirm` — as the
177
+ route [below](#as-routes) does.
178
+
179
+ ## Confirming the link
180
+
181
+ ```ts
182
+ const signedIn = await auth.magicLink.confirm(token);
183
+ // { status: 'signedIn', user, session, token }: the session is open
184
+ ```
185
+
186
+ `confirm` spends the token, marks the user's e-mail verified — the link
187
+ reached the inbox — and opens a session: the same `SignedIn` that `signIn`
188
+ answers. **The session opens in the browser that opened the link**, which
189
+ need not be the one that asked for it: a link asked for on a laptop and
190
+ opened on a phone signs the phone in. Where the sign-in must complete in
191
+ the browser that started it, send a [sign-in code](sign-in-code.md) instead.
192
+
193
+ The token is spent **by the first call**, in one conditional write, whether
194
+ that call signs in or is refused: of ten confirmations of one link at once,
195
+ exactly one signs in and nine answer `TOKEN_SPENT`. There is nothing to
196
+ guess — 256 random bits — so no attempts are counted.
197
+
198
+ ### Lifetime
199
+
200
+ A link lives **fifteen minutes** unless `tokens.magicLink` says otherwise,
201
+ and is `TOKEN_EXPIRED` after that. Fifteen rather than a code's ten: the
202
+ e-mail must arrive and be opened, and a scanner may hold it a minute first.
203
+ No longer: whoever reads the mailbox in that window signs in.
204
+
205
+ ```ts
206
+ janus({ ..., tokens: { magicLink: '30m' } });
207
+ ```
208
+
209
+ ### The e-mail is verified
210
+
211
+ A user whose `emailVerified` was `false` has it `true` once `confirm`
212
+ succeeds, and their `version` moves; a
213
+ [`user.emailVerified` event](events.md) is sent. A user already verified is
214
+ not written, and nothing is sent. The proof is written under the version
215
+ read: a user written meanwhile answers `VERSION_CONFLICT`, with the link
216
+ spent.
217
+
218
+ ### An account someone else registered
219
+
220
+ The proof can land on an account its owner did not create. `signUp` does
221
+ not wait for the e-mail to be proved, and `signIn` does not ask whether it
222
+ was, so anyone can register `ada@example.com` with a password of theirs. When
223
+ Ada later signs in by link, `confirm` marks the e-mail verified and opens her
224
+ session — **and the other password, and the session `signUp` opened, still
225
+ work**. A [password reset](email-flows.md) would have replaced both; a link
226
+ or a [code](sign-in-code.md) touches neither.
227
+
228
+ Where a password signs in before the e-mail is verified, **require
229
+ `verifyEmail` before `signIn`**, and the question does not arise. Otherwise
230
+ treat the first proof as a takeover of the address: `confirm` sends
231
+ [`user.emailVerified`](events.md) exactly when the e-mail was not verified
232
+ before, and awaits your listener before the session opens. Sign every
233
+ session out there — `signOutEverywhere(user)`; the one `confirm` opens comes
234
+ after — and have the user set a password of their own.
235
+
236
+ ### A second factor is still asked for
237
+
238
+ The link proves the e-mail, not the second factor. With
239
+ `janus({ secondFactor })`, a user whose factor is active gets **no session
240
+ from the link**: `confirm` answers a challenge, exactly as `signIn` does,
241
+ and [`secondFactor.confirm`](second-factor.md#confirming-the-code-at-sign-in)
242
+ redeems it with their app's code.
243
+
244
+ ```ts
245
+ const result = await auth.magicLink.confirm(token);
246
+ if (result.status === 'secondFactor') {
247
+ // result.challenge: for secondFactor.confirm — keep it as a sign-in keeps it
248
+ } else {
249
+ // result.token, result.session: set the cookie
250
+ }
251
+ ```
252
+
253
+ `confirm` answers `SignInResult` on a type whose `signIn` does — a type with
254
+ a password, in an instance given a `secondFactor` — and reading `token`
255
+ before narrowing on `status` is a compile error there. Anywhere else it
256
+ answers `SignedIn`.
257
+
258
+ ### What `confirm` refuses
259
+
260
+ | Rejects with | When | What to do |
261
+ | --- | --- | --- |
262
+ | `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 |
263
+ | `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 |
264
+ | `TOKEN_EXPIRED` | `expiresAt` has passed. The link is spent | ask for a new link |
265
+ | `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 |
266
+ | `USER_INACTIVE` | the user was deactivated since the link was sent. The link is spent | answer 403 |
267
+ | `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 |
268
+
269
+ Every refusal is a `TokenError` but `USER_INACTIVE`, a `UserInactiveError`;
270
+ with several user types the message starts with the type:
271
+ `patient.magicLink.confirm: …`. Every call may also reject with
272
+ `STORE_FAILED` — `request` as well as `confirm`: your 503, never "no such
273
+ account" nor a 401. [Troubleshooting](../troubleshooting.md#sign-in-links)
274
+ has each message with its cause.
275
+
276
+ ## What does not end a link
277
+
278
+ - **Writing the password.** `resetPassword.confirm`, `changePassword` and
279
+ `setPassword` spend the user's reset links and second-factor challenges,
280
+ not their sign-in links: the password proves nothing a link does, and
281
+ whoever holds the inbox can ask for another link anyway. The same holds
282
+ for sign-in codes.
283
+ - **Signing out everywhere.** It revokes sessions; a link opens a new one.
284
+ - **The sign-in throttle.** `signIn.throttle` counts passwords tried at one
285
+ login; a link is not a password, and its token cannot be guessed. Limit
286
+ `magicLink.request` yourself, as above.
287
+
288
+ What does: its own redemption, a newer `request`, its fifteen minutes, an
289
+ e-mail changed since (`TOKEN_STALE`), the user deactivated
290
+ (`USER_INACTIVE`), and deleting the user, which deletes every token of
291
+ theirs.
292
+
293
+ ## Options
294
+
295
+ | Option | Type | Default | Effect |
296
+ | --- | --- | --- | --- |
297
+ | `tokens.magicLink` | `Duration` | `'15m'` | How long a link can be confirmed |
298
+ | `email` | a field name | `'email'` | The field the link is sent to, and looked up by, per type |
299
+
300
+ A `tokens.magicLink` that is not a duration is refused when `janus()` is
301
+ called, with a `TypeError`: `janus: tokens.magicLink: "<value>" is not a
302
+ duration; …`.
303
+
304
+ ## As routes
305
+
306
+ The page above, and a fetch-style pair beside it — the shape Bun and most
307
+ frameworks hand you;
308
+ [`@nxgt/janus-hono`](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus-hono/docs/guide/routes.md#a-link-sent-by-e-mail)
309
+ has the same in Hono, behind its `csrf()`:
310
+
311
+ ```ts
312
+ import { JanusError, TokenError, UserInactiveError } from '@nxgt/janus';
313
+
314
+ const ORIGIN = 'https://app.example';
315
+
316
+ // POST /sign-in/email/link — ask for a link
317
+ export async function requestLink(request: Request): Promise<Response> {
318
+ const { email } = (await request.json()) as { email: string };
319
+ const issued = await auth.magicLink.request(email);
320
+ if (issued !== null) {
321
+ const link = `${ORIGIN}/sign-in/link?token=${issued.token}`;
322
+ 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
323
+ }
324
+ return new Response(null, { status: 202 }); // the same answer either way
325
+ }
326
+
327
+ // POST /sign-in/link — the page's button
328
+ export async function confirmLink(request: Request): Promise<Response> {
329
+ // Only your own page posts here: another site's form would sign its visitor in.
330
+ if (request.headers.get('Origin') !== ORIGIN) return new Response(null, { status: 403 });
331
+ const token = String((await request.formData()).get('token') ?? '');
332
+ try {
333
+ const signedIn = await auth.magicLink.confirm(token);
334
+ return new Response(null, {
335
+ status: 303,
336
+ headers: { Location: '/', 'Set-Cookie': auth.cookie.serialize(signedIn.token, signedIn.session) },
337
+ });
338
+ } catch (error) {
339
+ if (error instanceof UserInactiveError) return new Response(null, { status: 403 });
340
+ if (error instanceof TokenError || (error instanceof JanusError && error.code === 'VERSION_CONFLICT')) {
341
+ return new Response(null, { status: 303, headers: { Location: '/sign-in?link=expired' } });
342
+ }
343
+ throw error; // STORE_FAILED: your 503
344
+ }
345
+ }
346
+ ```
347
+
348
+ A refused link sends the user back to ask for another: every `TOKEN_*`, and
349
+ the rare `VERSION_CONFLICT`, means the same thing to them. With a
350
+ `secondFactor` configured, narrow `confirm`'s answer on `status` before
351
+ reading `token`, and hand the challenge on to
352
+ [the second factor's route](second-factor.md#a-sign-in-with-a-code-as-routes).
353
+
354
+ ## In a test
355
+
356
+ No mailbox needed: `request` answers the token it would have sent.
357
+
358
+ ```ts
359
+ import { expect, it } from 'bun:test';
360
+ import { z } from 'zod';
361
+ import { createMemoryStores, fixedClock, janus } from '@nxgt/janus';
362
+
363
+ it('signs in with the e-mailed link, once, and not after fifteen minutes', async () => {
364
+ const clock = fixedClock(Date.UTC(2026, 0, 1));
365
+ const auth = janus({ user: z.object({ email: z.email() }), store: createMemoryStores(), clock });
366
+ await auth.create({ email: 'ada@example.com' });
367
+
368
+ const issued = await auth.magicLink.request('ada@example.com');
369
+ if (issued === null) throw new Error('expected a link');
370
+ expect((await auth.magicLink.confirm(issued.token)).user.emailVerified).toBe(true);
371
+ await expect(auth.magicLink.confirm(issued.token)).rejects.toMatchObject({ code: 'TOKEN_SPENT' });
372
+
373
+ const late = await auth.magicLink.request('ada@example.com');
374
+ if (late === null) throw new Error('expected a link');
375
+ clock.advance(15 * 60_000);
376
+ await expect(auth.magicLink.confirm(late.token)).rejects.toMatchObject({ code: 'TOKEN_EXPIRED' });
377
+ });
378
+ ```
379
+
380
+ ## Signatures
381
+
382
+ ```ts
383
+ interface MagicLinkApi<U, Answer = SignedIn<U>> {
384
+ readonly magicLink: {
385
+ request(email: string): Promise<(IssuedToken & { readonly user: U }) | null>;
386
+ confirm(token: string): Promise<Answer>;
387
+ };
388
+ }
389
+ ```
390
+
391
+ `Answer` is `SignInResult<U>` on a type with a password in an instance given
392
+ a `secondFactor`, and `SignedIn<U>` everywhere else. `MagicLinkApi` and
393
+ `IssuedToken` are exported from `@nxgt/janus`, as types.
394
+
395
+ ## For an adapter
396
+
397
+ A link is a token of kind **`magicLink`**, new in 0.15. A store that lists
398
+ the kinds — a `CHECK`, a validator's enum — lists it too; the conformance
399
+ suite's `tokens.everyKind` fails until it does, and `tokens.magicLinkKind`
400
+ holds that a link and a sign-in code are never redeemed as each other. See
401
+ [adapters](adapters.md#sessionstore-and-tokenstore).
402
+
403
+ ## See also
404
+
405
+ - [Sign-in codes](sign-in-code.md) — the same sign-in with a code to type, which completes in the browser that asked
406
+ - [The second factor](second-factor.md) — the challenge `confirm` answers for a user whose factor is active
407
+ - [E-mail verification and password reset](email-flows.md) — the other flows that send a link, and `TOKEN_STALE`
408
+ - [Sessions](sessions.md) — the cookie the session is sent in
409
+ - [Errors](errors.md) — every code and its status
410
+ - [Troubleshooting](../troubleshooting.md#sign-in-links) — by the message you see
411
+ - [`@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
@@ -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 wire them, still opens
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 `signInCode.request`, which send e-mail: limit
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}`))) {
@@ -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
 
@@ -874,6 +874,7 @@ with `STORE_FAILED`.
874
874
  ## See also
875
875
 
876
876
  - [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
877
+ - [Sign-in links](magic-link.md) — a sign-in by e-mailed link, which does the same
877
878
  - [Sessions](sessions.md) — the cookie `confirm`'s session is sent in, and `authenticatedAt`
878
879
  - [User events](events.md) — `user.secondFactorEnabled`, `user.secondFactorDisabled`, `user.recoveryCodesRegenerated` and `user.recoveryCodeUsed`
879
880
  - [Errors](errors.md) — `CODE_INVALID`, `SECOND_FACTOR_NOT_ENROLLED`, `SECOND_FACTOR_ACTIVE` and their statuses
@@ -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; the earlier
132
- ones stay valid until they are confirmed or expire. `janus` does not limit
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 not this flow: the code is typed into the page that asked for it, so the
158
- sign-in completes in the browser that started it.
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,6 +274,12 @@ 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
+ **The proof can land on an account someone else registered** — with a
278
+ password of theirs, which keeps working, as does the session `signUp`
279
+ opened. Where a password signs in before the e-mail is verified, see
280
+ [the sign-in link guide](magic-link.md#an-account-someone-else-registered):
281
+ the same holds for a code.
282
+
276
283
  ### A second factor is still asked for
277
284
 
278
285
  The code proves the e-mail, not the second factor. With `janus({ secondFactor })`,
@@ -499,6 +506,7 @@ a `secondFactor`, and `SignedIn<U>` everywhere else. `IssuedCode` and
499
506
  ## See also
500
507
 
501
508
  - [The second factor](second-factor.md) — the challenge `confirm` answers for a user whose factor is active
509
+ - [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
510
  - [E-mail verification and password reset](email-flows.md) — the other flows that send an e-mail, and `TOKEN_STALE`
503
511
  - [Sessions](sessions.md) — the cookie the session is sent in
504
512
  - [Errors](errors.md) — every code and its status