@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.
- package/README.md +132 -25
- package/dist/auth/config/index.d.ts +1 -1
- package/dist/auth/config/index.d.ts.map +1 -1
- package/dist/auth/config/janus-config.d.ts +25 -0
- package/dist/auth/config/janus-config.d.ts.map +1 -1
- package/dist/auth/config/resolve-config.d.ts.map +1 -1
- package/dist/auth/config/resolve-sign-in.d.ts +5 -0
- package/dist/auth/config/resolve-sign-in.d.ts.map +1 -0
- package/dist/auth/config/resolved-config.d.ts +6 -0
- package/dist/auth/config/resolved-config.d.ts.map +1 -1
- package/dist/auth/derived-keys.d.ts +9 -0
- package/dist/auth/derived-keys.d.ts.map +1 -0
- package/dist/auth/email-sign-in.d.ts +40 -0
- package/dist/auth/email-sign-in.d.ts.map +1 -0
- package/dist/auth/events.d.ts +22 -6
- package/dist/auth/events.d.ts.map +1 -1
- package/dist/auth/index.d.ts +2 -2
- 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/one-time/index.d.ts +3 -1
- package/dist/auth/one-time/index.d.ts.map +1 -1
- package/dist/auth/one-time/window.d.ts +50 -0
- package/dist/auth/one-time/window.d.ts.map +1 -0
- package/dist/auth/password-written.d.ts +7 -0
- package/dist/auth/password-written.d.ts.map +1 -1
- package/dist/auth/port/types/tokens.d.ts +6 -3
- package/dist/auth/port/types/tokens.d.ts.map +1 -1
- package/dist/auth/second-factor/app-code-attempts.d.ts +5 -8
- package/dist/auth/second-factor/app-code-attempts.d.ts.map +1 -1
- package/dist/auth/second-factor/challenge.d.ts.map +1 -1
- package/dist/auth/second-factor/recovery-codes.d.ts.map +1 -1
- package/dist/auth/second-factor/recovery.d.ts.map +1 -1
- package/dist/auth/sign-in-attempts.d.ts +43 -0
- package/dist/auth/sign-in-attempts.d.ts.map +1 -0
- 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 +3 -3
- 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 +46 -0
- package/dist/auth/types/magic-link.d.ts.map +1 -0
- package/dist/auth/types/password.d.ts +8 -0
- package/dist/auth/types/password.d.ts.map +1 -1
- 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/fields-patch.d.ts +8 -2
- package/dist/auth/users/fields-patch.d.ts.map +1 -1
- package/dist/auth/users/record-flows.d.ts.map +1 -1
- package/dist/auth/users/sign-in.d.ts +2 -2
- package/dist/auth/users/sign-in.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/auth/users/update.d.ts +12 -0
- package/dist/auth/users/update.d.ts.map +1 -0
- package/dist/chunks/{index-km7ffctx.js → index-6c9fbtv2.js} +2 -2
- package/dist/chunks/{index-rd2rddvv.js → index-baazjyvc.js} +2 -2
- package/dist/chunks/{index-j2gd8fry.js → index-hnwggyxs.js} +3 -2
- package/dist/chunks/{index-j2gd8fry.js.map → index-hnwggyxs.js.map} +3 -3
- package/dist/chunks/{index-jp2kka6d.js → index-kj3st57z.js} +2 -2
- 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 +26 -4
- package/dist/conformance/index.js.map +3 -3
- package/dist/errors/janus-error/base.d.ts +1 -0
- package/dist/errors/janus-error/base.d.ts.map +1 -1
- package/dist/errors/janus-error/codes.d.ts +3 -1
- package/dist/errors/janus-error/codes.d.ts.map +1 -1
- package/dist/errors/janus-error/options.d.ts +9 -2
- package/dist/errors/janus-error/options.d.ts.map +1 -1
- package/dist/index.js +296 -101
- package/dist/index.js.map +24 -17
- package/dist/permissions/index.js +3 -3
- package/docs/README.md +4 -3
- package/docs/guide/adapters.md +11 -7
- package/docs/guide/email-flows.md +11 -7
- package/docs/guide/errors.md +12 -4
- package/docs/guide/events.md +75 -14
- package/docs/guide/magic-link.md +411 -0
- package/docs/guide/passwords.md +116 -17
- package/docs/guide/second-factor.md +6 -3
- package/docs/guide/sign-in-code.md +14 -6
- package/docs/guide/users.md +13 -7
- package/docs/guide/vocabulary.md +6 -5
- package/docs/roadmap.md +37 -23
- package/docs/troubleshooting.md +203 -9
- package/package.json +1 -1
- /package/dist/chunks/{index-km7ffctx.js.map → index-6c9fbtv2.js.map} +0 -0
- /package/dist/chunks/{index-rd2rddvv.js.map → index-baazjyvc.js.map} +0 -0
- /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
|
package/docs/guide/passwords.md
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# Password hashing
|
|
2
2
|
|
|
3
|
-
This page is for choosing a password hasher, moving from one to another,
|
|
4
|
-
importing hashes written by another system
|
|
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
|
-
##
|
|
126
|
+
## Password guessing is throttled
|
|
126
127
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
136
|
-
|
|
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
|
|
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,
|
|
310
|
-
|
|
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
|
|
132
|
-
ones
|
|
133
|
-
how often a code is asked for: **rate-limit the request route** per address
|
|
131
|
+
Every `request` issues a **new** code with its own challenge, and spends the
|
|
132
|
+
earlier ones: only the last code sent works — see
|
|
133
|
+
[attempts](#attempts). `janus` does not limit how often a code is asked for: **rate-limit the request route** per address
|
|
134
134
|
and per client, as you would a password reset, or anyone can fill a user's
|
|
135
135
|
inbox.
|
|
136
136
|
|
|
@@ -153,9 +153,10 @@ if (issued !== null) {
|
|
|
153
153
|
|
|
154
154
|
**No link, and no challenge.** A link carrying the challenge would put it in
|
|
155
155
|
the mailbox beside the code — whoever reads the e-mail would hold both
|
|
156
|
-
halves — and in every server log and proxy the link crosses. A sign-in link
|
|
157
|
-
is
|
|
158
|
-
sign-in completes in the browser that started
|
|
156
|
+
halves — and in every server log and proxy the link crosses. A [sign-in link](magic-link.md)
|
|
157
|
+
is another flow, with a token of its own: here the code is typed into the
|
|
158
|
+
page that asked for it, so the sign-in completes in the browser that started
|
|
159
|
+
it.
|
|
159
160
|
|
|
160
161
|
Send it to `issued.email`, not to what the visitor typed: it is the address
|
|
161
162
|
the user's field holds, as they registered it.
|
|
@@ -273,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
|