@nxgt/janus 0.4.0 → 0.6.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 +159 -20
- package/dist/auth/config.d.ts +28 -1
- package/dist/auth/config.d.ts.map +1 -1
- package/dist/auth/context.d.ts +6 -0
- package/dist/auth/context.d.ts.map +1 -1
- package/dist/auth/index.d.ts +3 -2
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/one-time.d.ts +92 -0
- package/dist/auth/one-time.d.ts.map +1 -0
- package/dist/auth/port/types.d.ts +10 -7
- package/dist/auth/port/types.d.ts.map +1 -1
- package/dist/auth/sealing.d.ts +39 -0
- package/dist/auth/sealing.d.ts.map +1 -0
- package/dist/auth/second-factor/challenge.d.ts +14 -0
- package/dist/auth/second-factor/challenge.d.ts.map +1 -0
- package/dist/auth/second-factor/factor.d.ts +27 -0
- package/dist/auth/second-factor/factor.d.ts.map +1 -0
- package/dist/auth/second-factor/flows.d.ts +23 -0
- package/dist/auth/second-factor/flows.d.ts.map +1 -0
- package/dist/auth/second-factor/lifecycle.d.ts +8 -0
- package/dist/auth/second-factor/lifecycle.d.ts.map +1 -0
- package/dist/auth/sessions.d.ts.map +1 -1
- package/dist/auth/sign-in-code.d.ts +17 -0
- package/dist/auth/sign-in-code.d.ts.map +1 -0
- package/dist/auth/totp.d.ts +36 -0
- package/dist/auth/totp.d.ts.map +1 -0
- package/dist/auth/types.d.ts +140 -7
- package/dist/auth/types.d.ts.map +1 -1
- package/dist/auth/users.d.ts +2 -2
- package/dist/auth/users.d.ts.map +1 -1
- package/dist/chunks/{index-qwfkhqkk.js → index-06vp9c5r.js} +12 -3
- package/dist/chunks/{index-qwfkhqkk.js.map → index-06vp9c5r.js.map} +3 -3
- package/dist/chunks/{index-53y1afjz.js → index-5vr13kkb.js} +2 -2
- package/dist/chunks/{index-mgh85djb.js → index-c4v27jfr.js} +2 -2
- package/dist/chunks/{index-thtyq7a9.js → index-gbwn2tts.js} +2 -2
- package/dist/conformance/cases/tokens.d.ts.map +1 -1
- package/dist/conformance/index.js +15 -4
- package/dist/conformance/index.js.map +3 -3
- package/dist/errors/janus-error.d.ts +28 -2
- package/dist/errors/janus-error.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +478 -51
- package/dist/index.js.map +15 -7
- package/dist/permissions/index.js +3 -3
- package/docs/README.md +2 -0
- package/docs/guide/adapters.md +12 -7
- package/docs/guide/email-flows.md +4 -0
- package/docs/guide/errors.md +19 -7
- package/docs/guide/second-factor.md +571 -0
- package/docs/guide/sessions.md +18 -0
- package/docs/guide/sign-in-code.md +471 -0
- package/docs/guide/users.md +10 -3
- package/docs/guide/vocabulary.md +9 -6
- package/docs/roadmap.md +39 -54
- package/docs/troubleshooting.md +414 -5
- package/package.json +1 -1
- /package/dist/chunks/{index-53y1afjz.js.map → index-5vr13kkb.js.map} +0 -0
- /package/dist/chunks/{index-mgh85djb.js.map → index-c4v27jfr.js.map} +0 -0
- /package/dist/chunks/{index-thtyq7a9.js.map → index-gbwn2tts.js.map} +0 -0
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
# Signing in with an e-mailed code
|
|
2
|
+
|
|
3
|
+
This page is for signing a user in with a six-digit code sent to their
|
|
4
|
+
e-mail: no password needed, and the code proves the address. `janus` issues
|
|
5
|
+
and checks the code; **sending the e-mail is yours**.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
import { createMemoryStores, janus, scryptHasher } from '@nxgt/janus';
|
|
10
|
+
|
|
11
|
+
async function sendMail(to: string, subject: string, text: string): Promise<void> {
|
|
12
|
+
// your mailer
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const auth = janus({
|
|
16
|
+
user: z.object({ email: z.email() }),
|
|
17
|
+
password: { login: 'email' },
|
|
18
|
+
store: createMemoryStores(),
|
|
19
|
+
hasher: scryptHasher(),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const issued = await auth.signInCode.request('ada@example.com'); // IssuedCode | null
|
|
23
|
+
if (issued !== null) {
|
|
24
|
+
await sendMail(issued.email, 'Your sign-in code', `${issued.code} signs you in. It expires in 10 minutes.`);
|
|
25
|
+
|
|
26
|
+
// keep issued.challenge with the visitor; on their next request, with the code they typed:
|
|
27
|
+
const signedIn = await auth.signInCode.confirm(issued.challenge, issued.code);
|
|
28
|
+
signedIn.token; // a session, as signIn opens one
|
|
29
|
+
signedIn.user.emailVerified; // true: the code reached the inbox
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
A sign-in by code is two requests: one asks for a code and answers the same
|
|
34
|
+
page whoever asked, the other takes the code and opens the session. The
|
|
35
|
+
words — **one-time code**, **challenge**, **attempt** — are defined in
|
|
36
|
+
[the vocabulary](vocabulary.md#identities).
|
|
37
|
+
|
|
38
|
+
## Which types have it
|
|
39
|
+
|
|
40
|
+
`signInCode` exists on every user type with an e-mail — the field `email`
|
|
41
|
+
names, or a required string field called `email` — **with or without a
|
|
42
|
+
password**. A type with no e-mail has no `signInCode`: it is absent from its
|
|
43
|
+
type, not failing at run time.
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
const clinic = janus({
|
|
47
|
+
users: {
|
|
48
|
+
patient: { schema: z.object({ email: z.email() }), password: { login: 'email' } },
|
|
49
|
+
staff: { schema: z.object({ username: z.string() }), password: { login: 'username' } },
|
|
50
|
+
member: { schema: z.object({ email: z.email() }) }, // no password: signs in by code only
|
|
51
|
+
},
|
|
52
|
+
store: createMemoryStores(),
|
|
53
|
+
hasher: scryptHasher(),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
clinic.patient.signInCode.request; // exists
|
|
57
|
+
clinic.member.signInCode.request; // exists: see Passwordless types, below
|
|
58
|
+
// @ts-expect-error — staff have no e-mail to send a code to
|
|
59
|
+
clinic.staff.signInCode;
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Requesting a code
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
const issued = await auth.signInCode.request(email);
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`request` looks up the user of this type holding that e-mail — trimmed and
|
|
69
|
+
lowercased first, so `' ADA@example.com '` finds `ada@example.com` — and
|
|
70
|
+
issues a code for them:
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
interface IssuedCode<U> {
|
|
74
|
+
readonly code: string; // six digits, '042817': for the e-mail, and nowhere else
|
|
75
|
+
readonly challenge: string; // the secret the code is checked against: for the visitor, never the e-mail
|
|
76
|
+
readonly email: string; // the address to send the code to, as the user's field holds it
|
|
77
|
+
readonly expiresAt: Date; // ten minutes from now, by default
|
|
78
|
+
readonly user: U;
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
It answers **`null`** when nobody of this type holds that e-mail, when the
|
|
83
|
+
user is inactive, and when the value matches a login that only looks like an
|
|
84
|
+
e-mail — a username `ada@example.com` on a type whose e-mail is another
|
|
85
|
+
field. Nothing is issued, nothing is written.
|
|
86
|
+
|
|
87
|
+
**Never tell the visitor which.** Answer the same page, with the same
|
|
88
|
+
headers, whether `request` issued a code or not — "if an account uses that
|
|
89
|
+
address, we sent it a code". A different status, body or cookie is an
|
|
90
|
+
account-enumeration oracle: it tells anyone who types an address whether it
|
|
91
|
+
has an account. When the challenge travels in a cookie, set one on the
|
|
92
|
+
`null` path too, with a random value of the same shape, so the headers match
|
|
93
|
+
— confirming it is `TOKEN_UNKNOWN`, like any wrong challenge:
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
import { randomBytes } from 'node:crypto';
|
|
97
|
+
|
|
98
|
+
const issued = await auth.signInCode.request(email);
|
|
99
|
+
// 32 random bytes, base64url — the shape of a real challenge, which nothing will ever accept
|
|
100
|
+
const challenge = issued?.challenge ?? randomBytes(32).toString('base64url');
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The decoy makes **the request** answer the same; **the confirmation** still
|
|
104
|
+
tells, if it answers each refusal as it comes. Anyone can ask a code for an
|
|
105
|
+
address, then send a wrong one: a decoy's challenge is `TOKEN_UNKNOWN`, a
|
|
106
|
+
real one's is `CODE_INVALID` with `attemptsLeft`. When the addresses of your
|
|
107
|
+
users must stay secret, answer every refusal of the code route alike, and
|
|
108
|
+
give up `attemptsLeft`:
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
import { JanusError } from '@nxgt/janus';
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
const signedIn = await auth.signInCode.confirm(challenge, code);
|
|
115
|
+
// … set the session cookie, as below
|
|
116
|
+
} catch (error) {
|
|
117
|
+
if (error instanceof JanusError && error.code !== 'STORE_FAILED') {
|
|
118
|
+
// one answer for a wrong code, a spent or unknown challenge — a decoy's included
|
|
119
|
+
return Response.json({ code: 'CODE_INVALID' }, { status: 401 });
|
|
120
|
+
}
|
|
121
|
+
throw error; // STORE_FAILED: your 503
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Send the e-mail **after** answering, from a queue or a promise you do not
|
|
126
|
+
await: a request that sends an e-mail takes longer than one that does not,
|
|
127
|
+
and the time the answer takes would tell what its body does not. The store's
|
|
128
|
+
own latency — one write for a code issued, none for `null` — still tells a
|
|
129
|
+
patient observer; that limit is stated rather than denied.
|
|
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
|
|
134
|
+
and per client, as you would a password reset, or anyone can fill a user's
|
|
135
|
+
inbox.
|
|
136
|
+
|
|
137
|
+
## Sending the code by e-mail
|
|
138
|
+
|
|
139
|
+
Put **the code, and only the code**, in the e-mail — with its lifetime, so
|
|
140
|
+
the visitor knows how long they have:
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
if (issued !== null) {
|
|
144
|
+
const minutes = Math.round((issued.expiresAt.getTime() - Date.now()) / 60_000);
|
|
145
|
+
void sendMail(
|
|
146
|
+
issued.email,
|
|
147
|
+
'Your sign-in code',
|
|
148
|
+
`Your code is ${issued.code}. It expires in ${minutes} minutes.\n` +
|
|
149
|
+
'If you did not ask for it, ignore this e-mail: nobody can sign in without it.',
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
**No link, and no challenge.** A link carrying the challenge would put it in
|
|
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.
|
|
159
|
+
|
|
160
|
+
Send it to `issued.email`, not to what the visitor typed: it is the address
|
|
161
|
+
the user's field holds, as they registered it.
|
|
162
|
+
|
|
163
|
+
## Keeping the challenge with the visitor
|
|
164
|
+
|
|
165
|
+
**The challenge is a secret.** The code alone signs nobody in: it is checked
|
|
166
|
+
against the challenge, and only its hash is stored, keyed by the challenge.
|
|
167
|
+
Whoever holds the challenge needs only the six digits, so keep it where the
|
|
168
|
+
visitor's next request presents it, and nowhere else:
|
|
169
|
+
|
|
170
|
+
- **a short-lived cookie** — `HttpOnly`, `Secure`, `SameSite=Strict`, a
|
|
171
|
+
`Path` covering only the code route, and a `Max-Age` ending at `expiresAt`;
|
|
172
|
+
- or **the body of the code form** — a hidden field of the page that asks
|
|
173
|
+
for the code;
|
|
174
|
+
- or, for a bearer client, the body of the answer, which it sends back with
|
|
175
|
+
the code.
|
|
176
|
+
|
|
177
|
+
**Never in the e-mail, never in a URL** — a query string reaches server logs,
|
|
178
|
+
proxies, the browser's history and the `Referer` header — **and never in a
|
|
179
|
+
log**. Log the user's id if you need to. The challenge is stored only as its
|
|
180
|
+
hash, like a session token, so the store cannot give it back either.
|
|
181
|
+
|
|
182
|
+
A challenge belongs to the user type that issued it: confirm a patient's
|
|
183
|
+
challenge with `clinic.patient.signInCode.confirm`. Another type's `confirm`
|
|
184
|
+
answers `TOKEN_UNKNOWN`, compares no code and spends nothing — but it has
|
|
185
|
+
already cost one of the challenge's five attempts, because the attempt is
|
|
186
|
+
counted before the type is known. The challenge is left for its own type,
|
|
187
|
+
with one attempt fewer.
|
|
188
|
+
|
|
189
|
+
## Confirming the code
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
const signedIn = await auth.signInCode.confirm(challenge, code);
|
|
193
|
+
// { status: 'signedIn', user, session, token }: the session is open
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
`confirm` checks the code against its challenge, spends the challenge, marks
|
|
197
|
+
the user's e-mail verified — the code reached the inbox — and opens a
|
|
198
|
+
session: the same `SignedIn` that `signIn` answers.
|
|
199
|
+
|
|
200
|
+
### Attempts
|
|
201
|
+
|
|
202
|
+
A challenge takes **five attempts**. Every call to `confirm` counts one, in
|
|
203
|
+
one write to the store, **before the code is compared** — a code that is not
|
|
204
|
+
six digits, a code that races another: each costs an attempt. `attemptsLeft`
|
|
205
|
+
counts down `4, 3, 2, 1, 0`; the fifth wrong code spends the challenge, and
|
|
206
|
+
the next call is `TOKEN_SPENT`, even with the right code.
|
|
207
|
+
|
|
208
|
+
```ts
|
|
209
|
+
import { TokenError } from '@nxgt/janus';
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
await auth.signInCode.confirm(challenge, code);
|
|
213
|
+
} catch (error) {
|
|
214
|
+
if (error instanceof TokenError && error.code === 'CODE_INVALID') {
|
|
215
|
+
error.attemptsLeft; // 4 after the first wrong code; 0 once the challenge is spent
|
|
216
|
+
}
|
|
217
|
+
throw error;
|
|
218
|
+
}
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
Five attempts at a million values is a one-in-200,000 chance per challenge.
|
|
222
|
+
Codes sent at once past the fifth attempt are all refused, the right one
|
|
223
|
+
included: the store counts, and nothing reads the count before writing it.
|
|
224
|
+
A new challenge takes a new `request` and a new e-mail — which is why that
|
|
225
|
+
route is the one to rate-limit.
|
|
226
|
+
|
|
227
|
+
### Lifetime
|
|
228
|
+
|
|
229
|
+
A challenge lives **ten minutes** unless `tokens.signInCode` says otherwise,
|
|
230
|
+
and is `TOKEN_EXPIRED` after that. Ten minutes is time for an e-mail to
|
|
231
|
+
arrive and be read; a longer window is a longer life for a stolen challenge.
|
|
232
|
+
|
|
233
|
+
```ts
|
|
234
|
+
janus({ ..., tokens: { signInCode: '15m' } });
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
### The e-mail is verified
|
|
238
|
+
|
|
239
|
+
A user whose `emailVerified` was `false` has it `true` once `confirm`
|
|
240
|
+
succeeds, and their `version` moves: the code proves the address as a
|
|
241
|
+
verification link would. A user already verified is not written.
|
|
242
|
+
|
|
243
|
+
### A second factor is still asked for
|
|
244
|
+
|
|
245
|
+
The code proves the e-mail, not the second factor. With `janus({ secondFactor })`,
|
|
246
|
+
a user whose factor is active gets **no session from the code**: `confirm`
|
|
247
|
+
answers a challenge, exactly as `signIn` does with a password, and
|
|
248
|
+
[`secondFactor.confirm`](second-factor.md#confirming-the-code-at-sign-in)
|
|
249
|
+
redeems it with the code of their authenticator app.
|
|
250
|
+
|
|
251
|
+
```ts
|
|
252
|
+
const result = await auth.signInCode.confirm(challenge, code);
|
|
253
|
+
switch (result.status) {
|
|
254
|
+
case 'signedIn':
|
|
255
|
+
// result.token, result.session: set the cookie
|
|
256
|
+
break;
|
|
257
|
+
case 'secondFactor':
|
|
258
|
+
// result.challenge: a new challenge, for secondFactor.confirm — keep it as you kept this one
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
`confirm` answers `SignInResult` on a type whose `signIn` does — a type with
|
|
264
|
+
a password, in an instance given a `secondFactor` — and reading `token`
|
|
265
|
+
before narrowing on `status` is a compile error there. Anywhere else it
|
|
266
|
+
answers `SignedIn`.
|
|
267
|
+
|
|
268
|
+
### What `confirm` refuses
|
|
269
|
+
|
|
270
|
+
| Rejects with | When | What to do |
|
|
271
|
+
| --- | --- | --- |
|
|
272
|
+
| `CODE_INVALID`, with `attemptsLeft` | the code does not match, or is not six digits. Message: `signInCode.confirm: the code does not match, or was already used` | ask again while `attemptsLeft > 0`; at `0` the challenge is spent: request a new code |
|
|
273
|
+
| `TOKEN_UNKNOWN` | no such challenge — a typo, another user type's, one whose user was deleted, or one a store's TTL already dropped. Another type's challenge still loses one of its attempts | request a new code |
|
|
274
|
+
| `TOKEN_SPENT` | the challenge already signed someone in, or its attempts ran out | request a new code |
|
|
275
|
+
| `TOKEN_EXPIRED` | `expiresAt` has passed | request a new code |
|
|
276
|
+
| `TOKEN_STALE` | the user changed their e-mail since the code was sent. Message: `signInCode.confirm: the code was sent to an e-mail the user no longer has`. The challenge is spent | request a new code, to the current address |
|
|
277
|
+
| `USER_INACTIVE` | the user was deactivated since the code was sent. The challenge is spent | answer 403 |
|
|
278
|
+
| `VERSION_CONFLICT` | rare: another write to the user landed while `confirm` marked the e-mail verified. The challenge is spent | request a new code |
|
|
279
|
+
|
|
280
|
+
Every refusal is a `TokenError` but `USER_INACTIVE`, a `UserInactiveError`;
|
|
281
|
+
with several user types the message starts with the type:
|
|
282
|
+
`patient.signInCode.confirm: …`. The `TOKEN_*` messages name the challenge —
|
|
283
|
+
`no such challenge`, `the challenge was already used`, `the challenge has
|
|
284
|
+
expired`. Every call may also reject with `STORE_FAILED`: your 503, never a
|
|
285
|
+
401. [Troubleshooting](../troubleshooting.md#sign-in-codes) has each message
|
|
286
|
+
with its cause.
|
|
287
|
+
|
|
288
|
+
## Passwordless types
|
|
289
|
+
|
|
290
|
+
A user type with an e-mail and **no `password`** signs in by code alone.
|
|
291
|
+
Create its users with `create` — `signUp` and `signIn` need a password, and
|
|
292
|
+
do not exist on it:
|
|
293
|
+
|
|
294
|
+
```ts
|
|
295
|
+
import { z } from 'zod';
|
|
296
|
+
import { createMemoryStores, janus } from '@nxgt/janus';
|
|
297
|
+
|
|
298
|
+
const auth = janus({
|
|
299
|
+
user: z.object({ email: z.email(), name: z.string() }),
|
|
300
|
+
store: createMemoryStores(), // no password: no hasher needed
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
await auth.create({ email: 'ada@example.com', name: 'Ada' });
|
|
304
|
+
|
|
305
|
+
const issued = await auth.signInCode.request('ada@example.com');
|
|
306
|
+
if (issued !== null) {
|
|
307
|
+
const signedIn = await auth.signInCode.confirm(issued.challenge, issued.code);
|
|
308
|
+
signedIn.user.hasPassword; // false
|
|
309
|
+
}
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
A passwordless type has no second factor either, so its `confirm` always
|
|
313
|
+
answers `SignedIn`, even in an instance given a `secondFactor`:
|
|
314
|
+
|
|
315
|
+
```ts
|
|
316
|
+
const clinic = janus({
|
|
317
|
+
users: {
|
|
318
|
+
patient: { schema: z.object({ email: z.email() }), password: { login: 'email' } },
|
|
319
|
+
member: { schema: z.object({ email: z.email() }) },
|
|
320
|
+
},
|
|
321
|
+
store: createMemoryStores(),
|
|
322
|
+
hasher: scryptHasher(),
|
|
323
|
+
secondFactor: { issuer: 'Clinic', keys: [{ id: '2026-09', key: process.env.TOTP_KEY ?? '' }] },
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
const member = await clinic.member.signInCode.confirm(challenge, code);
|
|
327
|
+
member.token; // SignedIn: no status to narrow
|
|
328
|
+
const patient = await clinic.patient.signInCode.confirm(challenge, code);
|
|
329
|
+
if (patient.status === 'signedIn') patient.token; // SignInResult: narrow first
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
A user of a type **with** a password can sign in by code too, whether or not
|
|
333
|
+
they ever set one: `create` a user with no password, and the code is how
|
|
334
|
+
they get in until `setPassword`.
|
|
335
|
+
|
|
336
|
+
## Options
|
|
337
|
+
|
|
338
|
+
| Option | Type | Default | Effect |
|
|
339
|
+
| --- | --- | --- | --- |
|
|
340
|
+
| `tokens.signInCode` | `Duration` | `'10m'` | How long a challenge — and so its code — can be confirmed |
|
|
341
|
+
| `email` | a field name | `'email'` | The field the code is sent to, and looked up by, per type |
|
|
342
|
+
|
|
343
|
+
```ts
|
|
344
|
+
janus({
|
|
345
|
+
user: z.object({ contact: z.email() }),
|
|
346
|
+
email: 'contact', // request() looks up, and answers, this field
|
|
347
|
+
store: createMemoryStores(),
|
|
348
|
+
tokens: { signInCode: '5m' },
|
|
349
|
+
});
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
A `tokens.signInCode` that is not a duration is refused when `janus()` is
|
|
353
|
+
called, with a `TypeError`: `janus: tokens.signInCode: "<value>" is not a
|
|
354
|
+
duration; …`. The code is always six digits and a challenge always takes
|
|
355
|
+
five attempts; neither is configurable.
|
|
356
|
+
|
|
357
|
+
## As routes
|
|
358
|
+
|
|
359
|
+
A fetch-style pair of handlers — the shape Bun and most frameworks hand
|
|
360
|
+
you. The challenge travels in a cookie scoped to the code route;
|
|
361
|
+
[`@nxgt/janus-hono`](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus-hono/docs/guide/routes.md#a-code-sent-by-e-mail)
|
|
362
|
+
has the same in Hono.
|
|
363
|
+
|
|
364
|
+
```ts
|
|
365
|
+
import { randomBytes } from 'node:crypto';
|
|
366
|
+
import { JanusError, TokenError } from '@nxgt/janus';
|
|
367
|
+
|
|
368
|
+
const CHALLENGE = 'sign-in-code';
|
|
369
|
+
const scope = 'Path=/sign-in/email; HttpOnly; Secure; SameSite=Strict';
|
|
370
|
+
|
|
371
|
+
export async function requestCode(request: Request): Promise<Response> {
|
|
372
|
+
const { email } = (await request.json()) as { email: string };
|
|
373
|
+
const issued = await auth.signInCode.request(email);
|
|
374
|
+
if (issued !== null) {
|
|
375
|
+
void sendMail(issued.email, 'Your sign-in code', `Your code is ${issued.code}.`); // not awaited
|
|
376
|
+
}
|
|
377
|
+
// the same answer either way: a decoy challenge when nobody holds the e-mail
|
|
378
|
+
const challenge = issued?.challenge ?? randomBytes(32).toString('base64url');
|
|
379
|
+
return Response.json(
|
|
380
|
+
{ next: 'code' },
|
|
381
|
+
{ status: 202, headers: { 'Set-Cookie': `${CHALLENGE}=${challenge}; Max-Age=600; ${scope}` } },
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export async function confirmCode(request: Request): Promise<Response> {
|
|
386
|
+
const { code } = (await request.json()) as { code: string };
|
|
387
|
+
const challenge = request.headers
|
|
388
|
+
.get('cookie')
|
|
389
|
+
?.match(new RegExp(`(?:^|;\\s*)${CHALLENGE}=([^;]+)`))?.[1];
|
|
390
|
+
if (challenge === undefined) return Response.json({ code: 'TOKEN_UNKNOWN' }, { status: 400 });
|
|
391
|
+
|
|
392
|
+
try {
|
|
393
|
+
const signedIn = await auth.signInCode.confirm(challenge, code);
|
|
394
|
+
const headers = new Headers();
|
|
395
|
+
headers.append('Set-Cookie', auth.cookie.serialize(signedIn.token, signedIn.session));
|
|
396
|
+
headers.append('Set-Cookie', `${CHALLENGE}=; Max-Age=0; ${scope}`);
|
|
397
|
+
return Response.json({ id: signedIn.user.id }, { headers });
|
|
398
|
+
} catch (error) {
|
|
399
|
+
if (error instanceof TokenError && error.code === 'CODE_INVALID') {
|
|
400
|
+
return Response.json({ code: error.code, attemptsLeft: error.attemptsLeft }, { status: 401 });
|
|
401
|
+
}
|
|
402
|
+
if (error instanceof JanusError && error.code === 'USER_INACTIVE') {
|
|
403
|
+
return Response.json({ code: error.code }, { status: 403 });
|
|
404
|
+
}
|
|
405
|
+
if (error instanceof JanusError && error.code !== 'STORE_FAILED') {
|
|
406
|
+
return Response.json({ code: error.code }, { status: 400 }); // TOKEN_*: request a new code
|
|
407
|
+
}
|
|
408
|
+
throw error; // STORE_FAILED: your 503
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
`Max-Age=600` is the default ten minutes, written out so the decoy and the
|
|
414
|
+
real cookie are the same header; derive it from `tokens.signInCode` if you
|
|
415
|
+
change that. A wrong code leaves the cookie in place, so the visitor types
|
|
416
|
+
it again. These routes answer `attemptsLeft`, so the code route tells a
|
|
417
|
+
decoy from a real challenge; where that matters, use
|
|
418
|
+
[one answer for every refusal](#requesting-a-code) instead. With a `secondFactor` configured, narrow `confirm`'s answer on
|
|
419
|
+
`status` before reading `token`, and hand the new challenge on to
|
|
420
|
+
[the second factor's route](second-factor.md#a-sign-in-with-a-code-as-routes).
|
|
421
|
+
|
|
422
|
+
## In a test
|
|
423
|
+
|
|
424
|
+
No mailbox needed: `request` answers the code it would have sent.
|
|
425
|
+
|
|
426
|
+
```ts
|
|
427
|
+
import { expect, it } from 'bun:test';
|
|
428
|
+
import { z } from 'zod';
|
|
429
|
+
import { createMemoryStores, fixedClock, janus } from '@nxgt/janus';
|
|
430
|
+
|
|
431
|
+
it('signs in with the e-mailed code, and not after ten minutes', async () => {
|
|
432
|
+
const clock = fixedClock(Date.UTC(2026, 0, 1));
|
|
433
|
+
const auth = janus({ user: z.object({ email: z.email() }), store: createMemoryStores(), clock });
|
|
434
|
+
await auth.create({ email: 'ada@example.com' });
|
|
435
|
+
|
|
436
|
+
const issued = await auth.signInCode.request('ada@example.com');
|
|
437
|
+
if (issued === null) throw new Error('expected a code');
|
|
438
|
+
expect((await auth.signInCode.confirm(issued.challenge, issued.code)).user.emailVerified).toBe(true);
|
|
439
|
+
|
|
440
|
+
const late = await auth.signInCode.request('ada@example.com');
|
|
441
|
+
if (late === null) throw new Error('expected a code');
|
|
442
|
+
clock.advance(10 * 60_000);
|
|
443
|
+
await expect(auth.signInCode.confirm(late.challenge, late.code)).rejects.toMatchObject({
|
|
444
|
+
code: 'TOKEN_EXPIRED',
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
## Signatures
|
|
450
|
+
|
|
451
|
+
```ts
|
|
452
|
+
interface SignInCodeApi<U, Answer = SignedIn<U>> {
|
|
453
|
+
readonly signInCode: {
|
|
454
|
+
request(email: string): Promise<IssuedCode<U> | null>;
|
|
455
|
+
confirm(challenge: string, code: string): Promise<Answer>;
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
`Answer` is `SignInResult<U>` on a type with a password in an instance given
|
|
461
|
+
a `secondFactor`, and `SignedIn<U>` everywhere else. `IssuedCode` and
|
|
462
|
+
`SignInCodeApi` are exported from `@nxgt/janus`, as types.
|
|
463
|
+
|
|
464
|
+
## See also
|
|
465
|
+
|
|
466
|
+
- [The second factor](second-factor.md) — the challenge `confirm` answers for a user whose factor is active
|
|
467
|
+
- [E-mail verification and password reset](email-flows.md) — the other flows that send an e-mail, and `TOKEN_STALE`
|
|
468
|
+
- [Sessions](sessions.md) — the cookie the session is sent in
|
|
469
|
+
- [Errors](errors.md) — every code and its status
|
|
470
|
+
- [`@nxgt/janus-telemetry`](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus-telemetry/docs/guide/tracing.md#a-code-sent-by-e-mail) — the events a sign-in by code writes
|
|
471
|
+
- [Troubleshooting](../troubleshooting.md#sign-in-codes) — by the message you see
|
package/docs/guide/users.md
CHANGED
|
@@ -54,6 +54,7 @@ interface UserBase<Type extends string = string> {
|
|
|
54
54
|
readonly emailVerified: boolean;
|
|
55
55
|
readonly active: boolean;
|
|
56
56
|
readonly hasPassword: boolean; // the hash itself never reaches a user
|
|
57
|
+
readonly hasSecondFactor: boolean; // an active second factor: signIn asks for a code
|
|
57
58
|
readonly version: number; // one more on every write
|
|
58
59
|
readonly createdAt: Date;
|
|
59
60
|
readonly updatedAt: Date;
|
|
@@ -81,7 +82,7 @@ that cuts an emoji in half, say.
|
|
|
81
82
|
| `password.login` | a field name | — | The field users sign in with: a **top-level, required string** field. A typo is a compile error |
|
|
82
83
|
| `password.normalize` | `'none' \| 'lowercase' \| 'lowercaseTrim' \| 'nfkcLowercaseTrim' \| (value) => string` | `'lowercaseTrim'` | Applied to the login before any store sees it, at sign-up and at sign-in alike |
|
|
83
84
|
| `password.minLength` | integer ≥ 1 | `8` | Below it: `PASSWORD_TOO_SHORT` |
|
|
84
|
-
| `email` | a field name | `'email'` | The field `verifyEmail` and `
|
|
85
|
+
| `email` | a field name | `'email'` | The field `verifyEmail`, `resetPassword` and `signInCode` send to. Without one, those flows are absent from the type |
|
|
85
86
|
| `session.lifespan` | `Duration` | `'7d'` | How long a session lives |
|
|
86
87
|
| `session.renewAfter` | `Duration \| false` | `'1d'` | When `authenticate` slides the session. `false` for a fixed lifespan |
|
|
87
88
|
| `schemaVersion` | string | `'1'` | Recorded on every user written. Bump it when the schema tightens |
|
|
@@ -93,6 +94,8 @@ that cuts an emoji in half, say.
|
|
|
93
94
|
| `cookie` | `CookieConfig` | strict | See [sessions](sessions.md#the-cookie) |
|
|
94
95
|
| `tokens.verifyEmail` | `Duration` | `'24h'` | How long a verification token lives |
|
|
95
96
|
| `tokens.resetPassword` | `Duration` | `'1h'` | How long a reset token lives |
|
|
97
|
+
| `tokens.signInCode` | `Duration` | `'10m'` | How long an e-mailed sign-in code and its challenge live. See [sign-in codes](sign-in-code.md) |
|
|
98
|
+
| `secondFactor` | `{ issuer, keys, challenge? }` | none | A TOTP second factor for every type with a password. Changes what `signIn` answers — see [the second factor](second-factor.md#configuration) |
|
|
96
99
|
|
|
97
100
|
A `Duration` is `'500ms'`, `'30s'`, `'15m'`, `'8h'`, `'7d'`, or a number of
|
|
98
101
|
milliseconds.
|
|
@@ -181,12 +184,16 @@ With a `password`, besides:
|
|
|
181
184
|
|
|
182
185
|
| Method | Answers | Rejects with |
|
|
183
186
|
| --- | --- | --- |
|
|
184
|
-
| `signUp(fields & { password })` | `{ user, session, token }` | `USER_INVALID`, `PASSWORD_TOO_SHORT`, `LOGIN_TAKEN` |
|
|
185
|
-
| `signIn({ [login]: string, password })` | `{ user, session, token }` | `CREDENTIALS_INVALID`, `USER_INACTIVE`, `HASH_UNSUPPORTED` |
|
|
187
|
+
| `signUp(fields & { password })` | `{ status: 'signedIn', user, session, token }` | `USER_INVALID`, `PASSWORD_TOO_SHORT`, `LOGIN_TAKEN` |
|
|
188
|
+
| `signIn({ [login]: string, password })` | `{ status: 'signedIn', user, session, token }` — or, with `secondFactor` configured and the user's factor active, `{ status: 'secondFactor', challenge, expiresAt }`: switch on `status` | `CREDENTIALS_INVALID`, `USER_INACTIVE`, `HASH_UNSUPPORTED` |
|
|
186
189
|
| `findByLogin(login)` | the user, or `null`; the login is normalised first, and one holding a NUL or a lone surrogate is nobody's | |
|
|
187
190
|
| `setPassword(user, password, { ifVersion? })` | the user — an admin's call | `PASSWORD_TOO_SHORT` |
|
|
188
191
|
| `changePassword(user, { current, next }, { ifVersion? })` | the user — the user's own call | `CREDENTIALS_INVALID`, `PASSWORD_TOO_SHORT` |
|
|
189
192
|
|
|
193
|
+
With `secondFactor` configured, a type with a password also answers
|
|
194
|
+
`secondFactor.enroll`, `activate`, `disable` and `confirm` — see
|
|
195
|
+
[the second factor](second-factor.md).
|
|
196
|
+
|
|
190
197
|
Every method may also reject with `STORE_FAILED`. A `user` argument is a user
|
|
191
198
|
or its id (`UserRef = string | { id: string }`).
|
|
192
199
|
|
package/docs/guide/vocabulary.md
CHANGED
|
@@ -21,7 +21,7 @@ parseDuration('8h', 'session.lifespan'); // 28800000
|
|
|
21
21
|
## Words
|
|
22
22
|
|
|
23
23
|
One word per idea, the same in the README, these guides, the error messages
|
|
24
|
-
and the
|
|
24
|
+
and the source. The **Not** column lists the words it replaces, so a search for
|
|
25
25
|
either finds this row.
|
|
26
26
|
|
|
27
27
|
### The two sides
|
|
@@ -46,12 +46,15 @@ either finds this row.
|
|
|
46
46
|
| **lapsed**, **revoked**, **renewed** | A session past its `expiresAt`; one ended by a sign-out or a password reset; one whose `expiresAt` moved in passing (`renewAfter`) | "expired" — kept for `TOKEN_EXPIRED`, a one-time token |
|
|
47
47
|
| **anonymous** | A request that presents no session credential, or one that authenticates nobody: `authenticate` answers `null` | "unauthenticated", "guest", "logged out" |
|
|
48
48
|
| **bearer client** | A client that sends its session token as `Authorization: Bearer` rather than in a cookie | |
|
|
49
|
-
| **one-time token** | A single-use token sent by e-mail, for verification or a password reset | "code"
|
|
50
|
-
| **one-time code** |
|
|
51
|
-
| **second factor** | What a user proves at sign-in beyond their password: a TOTP secret their authenticator app holds, `UserRecord.secondFactor`. **Enrolled**
|
|
52
|
-
| **attempt** | One code tried against a
|
|
49
|
+
| **one-time token** | A single-use token sent by e-mail in a link, for verification or a password reset | "code": a code is typed, a token is followed in a link |
|
|
50
|
+
| **one-time code** | Six digits a user types, checked against a challenge. Two kinds: a **TOTP code**, computed by an authenticator app from the user's secret and never sent — what `secondFactor.activate` and `secondFactor.confirm` take, see [the second factor](second-factor.md); and a **sign-in code**, sent by e-mail — what `signInCode.request` issues and `signInCode.confirm` takes, see [sign-in codes](sign-in-code.md). **"Code" alone always means a one-time code**, the kind named by the sentence around it. An error's is written `error.code`, or by its value (`CODE_INVALID`), and said *error code*; the program is *your code* | "OTP", "PIN", "passcode", "magic code"; "code" for an error's `code`, unless written *error code* |
|
|
51
|
+
| **second factor** | What a user proves at sign-in beyond their password: a TOTP secret their authenticator app holds, `UserRecord.secondFactor`. **Enrolled** once `enroll` stored it, waiting for a first code (`confirmedAt: null`); **active** once `activate` accepted one — `hasSecondFactor`, and only then does `signIn` ask for a code | "2FA", "MFA", "OTP device"; "confirmed" for the state — to *confirm* is to redeem a challenge |
|
|
52
|
+
| **attempt** | One code tried against a challenge, counted by `countAttempt` in the token's `attempts` before the code is checked. A challenge takes five, a second factor's or a sign-in code's | "try"; "retry" is a repeated write, never a guess |
|
|
53
|
+
| **challenge** | A secret the visitor keeps, redeemed once with a code — which is what *confirm* means for it. `signIn` answers one instead of a session when the user's second factor is active, for `secondFactor.confirm`; `signInCode.request` answers one beside the sign-in code, for `signInCode.confirm`. Stored as its hash, as a one-time token of kind `secondFactor` or `signInCode`: five minutes or ten by default, and five attempts | "MFA token", "pending session", "ticket" |
|
|
54
|
+
| **step** | The thirty-second period a TOTP code belongs to. `lastStep` is the step of the last code accepted, and only a later step counts: that is why a code is accepted once | "window", "period", "counter", in prose |
|
|
55
|
+
| **seal**, **sealing key** | To seal is to encrypt a TOTP secret — AES-256-GCM, bound to the user's id — before a store sees it. A sealing key is one `{ id, key }` of `secondFactor.keys`: the first seals, every one opens | "encrypt", "encryption key", "master key", "pepper" |
|
|
53
56
|
| **token** | Never alone in prose: a *session token* or a *one-time token*. The `tokens` store and the `TOKEN_*` codes are one-time tokens only | |
|
|
54
|
-
| **e-mail flow** | `verifyEmail` or `resetPassword`: send a one-time token, then confirm it | |
|
|
57
|
+
| **e-mail flow** | `verifyEmail` or `resetPassword`: send a one-time token, then confirm it. `signInCode` sends a one-time code instead | |
|
|
55
58
|
|
|
56
59
|
### Permissions
|
|
57
60
|
|