@nxgt/janus 0.13.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 (92) hide show
  1. package/README.md +132 -25
  2. package/dist/auth/config/index.d.ts +1 -1
  3. package/dist/auth/config/index.d.ts.map +1 -1
  4. package/dist/auth/config/janus-config.d.ts +25 -0
  5. package/dist/auth/config/janus-config.d.ts.map +1 -1
  6. package/dist/auth/config/resolve-config.d.ts.map +1 -1
  7. package/dist/auth/config/resolve-sign-in.d.ts +5 -0
  8. package/dist/auth/config/resolve-sign-in.d.ts.map +1 -0
  9. package/dist/auth/config/resolved-config.d.ts +6 -0
  10. package/dist/auth/config/resolved-config.d.ts.map +1 -1
  11. package/dist/auth/derived-keys.d.ts +9 -0
  12. package/dist/auth/derived-keys.d.ts.map +1 -0
  13. package/dist/auth/email-sign-in.d.ts +40 -0
  14. package/dist/auth/email-sign-in.d.ts.map +1 -0
  15. package/dist/auth/events.d.ts +22 -6
  16. package/dist/auth/events.d.ts.map +1 -1
  17. package/dist/auth/index.d.ts +2 -2
  18. package/dist/auth/index.d.ts.map +1 -1
  19. package/dist/auth/magic-link.d.ts +22 -0
  20. package/dist/auth/magic-link.d.ts.map +1 -0
  21. package/dist/auth/one-time/index.d.ts +3 -1
  22. package/dist/auth/one-time/index.d.ts.map +1 -1
  23. package/dist/auth/one-time/window.d.ts +50 -0
  24. package/dist/auth/one-time/window.d.ts.map +1 -0
  25. package/dist/auth/password-written.d.ts +7 -0
  26. package/dist/auth/password-written.d.ts.map +1 -1
  27. package/dist/auth/port/types/tokens.d.ts +6 -3
  28. package/dist/auth/port/types/tokens.d.ts.map +1 -1
  29. package/dist/auth/second-factor/app-code-attempts.d.ts +5 -8
  30. package/dist/auth/second-factor/app-code-attempts.d.ts.map +1 -1
  31. package/dist/auth/second-factor/challenge.d.ts.map +1 -1
  32. package/dist/auth/second-factor/recovery-codes.d.ts.map +1 -1
  33. package/dist/auth/second-factor/recovery.d.ts.map +1 -1
  34. package/dist/auth/sign-in-attempts.d.ts +43 -0
  35. package/dist/auth/sign-in-attempts.d.ts.map +1 -0
  36. package/dist/auth/sign-in-code.d.ts +1 -3
  37. package/dist/auth/sign-in-code.d.ts.map +1 -1
  38. package/dist/auth/types/email-flows.d.ts +3 -3
  39. package/dist/auth/types/index.d.ts +1 -0
  40. package/dist/auth/types/index.d.ts.map +1 -1
  41. package/dist/auth/types/janus.d.ts +8 -1
  42. package/dist/auth/types/janus.d.ts.map +1 -1
  43. package/dist/auth/types/magic-link.d.ts +46 -0
  44. package/dist/auth/types/magic-link.d.ts.map +1 -0
  45. package/dist/auth/types/password.d.ts +8 -0
  46. package/dist/auth/types/password.d.ts.map +1 -1
  47. package/dist/auth/users/any-type-api.d.ts +2 -2
  48. package/dist/auth/users/any-type-api.d.ts.map +1 -1
  49. package/dist/auth/users/fields-patch.d.ts +8 -2
  50. package/dist/auth/users/fields-patch.d.ts.map +1 -1
  51. package/dist/auth/users/record-flows.d.ts.map +1 -1
  52. package/dist/auth/users/sign-in.d.ts +2 -2
  53. package/dist/auth/users/sign-in.d.ts.map +1 -1
  54. package/dist/auth/users/type-api.d.ts +2 -1
  55. package/dist/auth/users/type-api.d.ts.map +1 -1
  56. package/dist/auth/users/update.d.ts +12 -0
  57. package/dist/auth/users/update.d.ts.map +1 -0
  58. package/dist/chunks/{index-km7ffctx.js → index-6c9fbtv2.js} +2 -2
  59. package/dist/chunks/{index-rd2rddvv.js → index-baazjyvc.js} +2 -2
  60. package/dist/chunks/{index-j2gd8fry.js → index-hnwggyxs.js} +3 -2
  61. package/dist/chunks/{index-j2gd8fry.js.map → index-hnwggyxs.js.map} +3 -3
  62. package/dist/chunks/{index-jp2kka6d.js → index-kj3st57z.js} +2 -2
  63. package/dist/conformance/cases/tokens/kinds.d.ts +4 -1
  64. package/dist/conformance/cases/tokens/kinds.d.ts.map +1 -1
  65. package/dist/conformance/index.js +26 -4
  66. package/dist/conformance/index.js.map +3 -3
  67. package/dist/errors/janus-error/base.d.ts +1 -0
  68. package/dist/errors/janus-error/base.d.ts.map +1 -1
  69. package/dist/errors/janus-error/codes.d.ts +3 -1
  70. package/dist/errors/janus-error/codes.d.ts.map +1 -1
  71. package/dist/errors/janus-error/options.d.ts +9 -2
  72. package/dist/errors/janus-error/options.d.ts.map +1 -1
  73. package/dist/index.js +296 -101
  74. package/dist/index.js.map +24 -17
  75. package/dist/permissions/index.js +3 -3
  76. package/docs/README.md +4 -3
  77. package/docs/guide/adapters.md +11 -7
  78. package/docs/guide/email-flows.md +11 -7
  79. package/docs/guide/errors.md +12 -4
  80. package/docs/guide/events.md +75 -14
  81. package/docs/guide/magic-link.md +411 -0
  82. package/docs/guide/passwords.md +116 -17
  83. package/docs/guide/second-factor.md +6 -3
  84. package/docs/guide/sign-in-code.md +14 -6
  85. package/docs/guide/users.md +13 -7
  86. package/docs/guide/vocabulary.md +6 -5
  87. package/docs/roadmap.md +37 -23
  88. package/docs/troubleshooting.md +203 -9
  89. package/package.json +1 -1
  90. /package/dist/chunks/{index-km7ffctx.js.map → index-6c9fbtv2.js.map} +0 -0
  91. /package/dist/chunks/{index-rd2rddvv.js.map → index-baazjyvc.js.map} +0 -0
  92. /package/dist/chunks/{index-jp2kka6d.js.map → index-kj3st57z.js.map} +0 -0
@@ -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
@@ -1,7 +1,8 @@
1
1
  # Password hashing
2
2
 
3
- This page is for choosing a password hasher, moving from one to another, and
4
- importing hashes written by another system. The login and the length policy
3
+ This page is for choosing a password hasher, moving from one to another,
4
+ importing hashes written by another system, and how `signIn` throttles
5
+ password guessing. The login and the length policy
5
6
  are on the [users](users.md#options) page.
6
7
 
7
8
  ```ts
@@ -122,28 +123,126 @@ Write each imported user's hash as their `PasswordRecord` with your store's
122
123
  scrypt the first time they sign in. Wire one verifier
123
124
  per prefix the old system wrote (`$2a$`, `$2b$`, `$2y$` for bcrypt).
124
125
 
125
- ## Rate-limit password guessing
126
+ ## Password guessing is throttled
126
127
 
127
- **Rate-limit `signIn` per login and per client: `janus` counts no failed
128
- password**, so without a limit anyone can guess one user's password — or try
129
- one password against every login — as fast as your server hashes. A sign-in
130
- code, a second factor and a step-up each count their guesses; a password
131
- does not. Put the limit in front of the call, with the limiter you already
132
- run:
128
+ **`signIn` counts the passwords tried at each login, and past ten in a
129
+ 15-minute window it refuses every one — the right password included — until
130
+ the window ends.** On by default. The refusal is the same `CREDENTIALS_INVALID`
131
+ as a wrong password, with `reason: 'throttled'` for your logs and
132
+ `retryAfter`, the seconds until the next window, for the client:
133
133
 
134
134
  ```ts
135
- const key = `sign-in:${email.trim().toLowerCase()}`;
136
- if (!(await limiter.consume(key))) {
135
+ import { JanusError } from '@nxgt/janus';
136
+
137
+ try {
138
+ return await auth.signIn({ email, password });
139
+ } catch (error) {
140
+ if (error instanceof JanusError && error.code === 'CREDENTIALS_INVALID') {
141
+ const headers = error.retryAfter === undefined
142
+ ? undefined
143
+ : { 'retry-after': String(error.retryAfter) };
144
+ return Response.json({ code: error.code, retryAfter: error.retryAfter }, { status: 401, headers });
145
+ }
146
+ throw error;
147
+ }
148
+ ```
149
+
150
+ [`janusErrors()`](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus-hono/docs/guide/routes.md#sign-up-and-sign-in) in
151
+ `@nxgt/janus-hono` and `janusGraphQLError()` in `@nxgt/janus-graphql` do this
152
+ for you, `Retry-After` header included.
153
+
154
+ What it does, precisely:
155
+
156
+ - **Nothing locks.** The next window signs in: no number of wrong
157
+ passwords blocks an account beyond its window. **But the right password is
158
+ refused while the login is throttled**, so somebody who knows only a login
159
+ can keep its password sign-in shut by trying ten passwords every window.
160
+ That is the price of counting per login; limit per client address (below),
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.
164
+ - **Per login, known or not.** A login nobody holds is counted as a
165
+ registered one is — `unknownLogin` ten times, then `throttled` — so the
166
+ throttle does not say which logins exist; an unknown login is still
167
+ compared against a dummy hash, as before. The login is counted as
168
+ [`password.normalize`](users.md#passwordnormalize) writes it, within its
169
+ user type: `ADA@example.test` and `ada@example.test` share one count.
170
+ - **Counted before anything is compared**, by the store, in one write per
171
+ attempt: of twenty passwords tried at once, exactly ten are compared. A
172
+ throttled attempt compares nothing, so it costs no hashing either.
173
+ - **A sign-in that opens a session starts the count again** for that
174
+ login: the right password of an active user — and, with a second factor
175
+ active, only once its code or a recovery code opens the session. A
176
+ password that only opens a challenge restarts nothing, so knowing the
177
+ password buys no more than ten challenges per window.
178
+ - **The windows are fixed slices of the clock**, not sliding: ten attempts
179
+ at the end of one window and ten at the start of the next are allowed.
180
+
181
+ Change the limit or the window, or turn it off — `SignInConfig` and
182
+ `SignInThrottleConfig` are the option's types, for a wrapper of your own:
183
+
184
+ ```ts
185
+ janus({ ..., signIn: { throttle: { attempts: 5, window: '1h' } } });
186
+ janus({ ..., signIn: { throttle: false } }); // counts nothing: limit signIn yourself
187
+ ```
188
+
189
+ ### Where the counts live
190
+
191
+ In the tokens store, as `secondFactor` tokens that belong to no user: the
192
+ store needs no new method, and an adapter that passes the conformance suite
193
+ counts correctly. Each is named by a keyed hash of the login, never the login
194
+ — keyed by your `secondFactor` keys when you wire them, and by a fixed key
195
+ otherwise, which only keeps the login out of plain sight: a dump of the
196
+ tokens then tells which logins were tried, for a guessed list. Each expires
197
+ two windows after its window starts — half an hour by default — **Redis and MongoDB drop it then;
198
+ PostgreSQL keeps it** until something deletes it, and every login tried,
199
+ registered or not, adds a row per window. Schedule a delete with
200
+ `@nxgt/janus-drizzle`:
201
+
202
+ ```sql
203
+ delete from tokens where expires_at < now() - interval '1 hour'; -- janus.tokens when your tables have a schema of their own
204
+ ```
205
+
206
+ `tokens` has no index on `expires_at`, so the delete scans the table: add
207
+ `create index on tokens (expires_at)` in a migration of yours if it grows.
208
+
209
+ The key is your first `secondFactor` key: wiring
210
+ `secondFactor` for the first time, or putting a new key first, starts every
211
+ login's count again. **A Redis tokens store that is flushed, or evicts keys under
212
+ memory pressure, forgets them** and every count starts again: give Redis
213
+ `maxmemory-policy noeviction` (see `@nxgt/janus-redis`).
214
+
215
+ ### When the store cannot count
216
+
217
+ `signIn` throws `STORE_FAILED` — answer 503. It never answers a refusal the
218
+ visitor could not have caused, and never lets a password through uncounted:
219
+ **the throttle fails closed**, so a tokens-store outage stops password
220
+ sign-ins as a users-store outage would. A sign-in whose password was right and
221
+ whose count could not start again also throws `STORE_FAILED`, before any
222
+ session is opened.
223
+
224
+ The store's round-trips are observable, as its latency is: a login that
225
+ signed in this window costs a few more probes than one nobody holds.
226
+
227
+ ### What you still limit yourself
228
+
229
+ The throttle counts per login. It does not see:
230
+
231
+ - **One password tried against many logins** (password spraying), nor many
232
+ logins from one client: limit `signIn` **per client address** in front of
233
+ the call, with the limiter you already run.
234
+ - `changePassword`, which compares the current password too: limit it per
235
+ user.
236
+ - `resetPassword.request`, `signInCode.request` and `magicLink.request`,
237
+ which send e-mail: limit them per address.
238
+
239
+ ```ts
240
+ if (!(await limiter.consume(`sign-in:${clientAddress}`))) {
137
241
  return new Response(null, { status: 429, headers: { 'retry-after': '900' } });
138
242
  }
139
243
  const signedIn = await auth.signIn({ email, password });
140
244
  ```
141
245
 
142
- Key it on the login normalised as
143
- [`password.normalize`](users.md#passwordnormalize) does — `lowercaseTrim` by
144
- default, as above — and add a second limit per client address; a few
145
- attempts per quarter hour per login is plenty for a person. `changePassword` compares the current password too: limit it per user.
146
-
147
246
  ## What never happens
148
247
 
149
248
  - The plain password is never stored, and never appears in an error message.
@@ -155,4 +254,4 @@ attempts per quarter hour per login is plenty for a person. `changePassword` com
155
254
  ## See also
156
255
 
157
256
  - [Users](users.md) — `signIn`, `setPassword`, `changePassword`
158
- - [Errors](errors.md) — `CREDENTIALS_INVALID` and its `reason`
257
+ - [Errors](errors.md) — `CREDENTIALS_INVALID`, its `reason` and `retryAfter`
@@ -306,8 +306,10 @@ try {
306
306
  ```
307
307
 
308
308
  Five attempts at a million values is a one-in-200,000 chance per password
309
- guessed right. A new challenge takes a new sign-in, with the password, so the
310
- attempts are bounded by your sign-in rate limit too.
309
+ guessed right. A new challenge takes a new sign-in, with the password, and
310
+ `signIn`'s throttle allows ten per login per 15-minute window — so the
311
+ password alone buys at most ten challenges a window; a limit per client is
312
+ yours to add.
311
313
 
312
314
  A call made through **another user type's** API — `auth.staff.secondFactor.confirm`
313
315
  for a patient's challenge — answers `TOKEN_UNKNOWN` and compares nothing, but
@@ -382,7 +384,7 @@ is as good as the user's phone.
382
384
  ### Signing in with one
383
385
 
384
386
  `recover(challenge, code)` redeems the challenge `signIn` answered — or
385
- `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
386
388
  opens the session. The recovery code is **spent**: its hash leaves the store,
387
389
  and `recoveryCodesLeft` says how many remain.
388
390
 
@@ -872,6 +874,7 @@ with `STORE_FAILED`.
872
874
  ## See also
873
875
 
874
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
875
878
  - [Sessions](sessions.md) — the cookie `confirm`'s session is sent in, and `authenticatedAt`
876
879
  - [User events](events.md) — `user.secondFactorEnabled`, `user.secondFactorDisabled`, `user.recoveryCodesRegenerated` and `user.recoveryCodeUsed`
877
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