@nxgt/janus 0.5.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/docs/README.md CHANGED
@@ -14,6 +14,7 @@ below are defined once, in [Words](guide/vocabulary.md#words).
14
14
  | [Users](guide/users.md) | You are wiring `janus()`, declaring one or several user types, or calling `create`, `update`, `list`, `delete` |
15
15
  | [Sessions](guide/sessions.md) | You need to know who a request belongs to, set or clear the cookie, renew, sign out, or test expiry |
16
16
  | [E-mail verification and password reset](guide/email-flows.md) | You are sending a verification or reset link, and handling what comes back |
17
+ | [Signing in with an e-mailed code](guide/sign-in-code.md) | You are signing users in with a six-digit code sent by e-mail — with no password, or beside one: requesting it without telling who exists, keeping the challenge, the attempts and the errors |
17
18
  | [The second factor](guide/second-factor.md) | You are turning on TOTP codes: making and rotating the sealing keys, the QR code, `signIn`'s `status`, confirming a challenge, disabling |
18
19
  | [Password hashing](guide/passwords.md) | You are choosing a hasher, raising its cost, moving to argon2id, or importing hashes from another system |
19
20
 
@@ -51,6 +51,9 @@ clinic.patient.verifyEmail.send; // exists: `email` names the field
51
51
  clinic.staff.verifyEmail;
52
52
  ```
53
53
 
54
+ A type with an e-mail can also sign in with a code sent to it, with or
55
+ without a password — see [sign-in codes](sign-in-code.md).
56
+
54
57
  ## Options
55
58
 
56
59
  | Option | Type | Default | Effect |
@@ -142,5 +145,6 @@ never the token, and no refusal's message contains it.
142
145
  ## See also
143
146
 
144
147
  - [Users](users.md) — `email`, `update`, and the other per-type methods
148
+ - [Sign-in codes](sign-in-code.md) — the third flow that sends an e-mail: a code, not a link
145
149
  - [Sessions](sessions.md) — `signOutEverywhere`, which `resetPassword.confirm` calls for you
146
150
  - [Errors](errors.md) — every code, and the status it deserves
@@ -83,9 +83,9 @@ that wired the library, so no handler needs to tell it apart.
83
83
  | `PASSWORD_TOO_SHORT` | `CredentialError` | 400 | Below `password.minLength` | `minLength` — never the password |
84
84
  | `CREDENTIALS_INVALID` | `CredentialError` | 401 | Unknown login, no password, or the wrong one — **one code for the three** | `reason`, for your logs only |
85
85
  | `HASH_UNSUPPORTED` | `CredentialError` | 400 | A stored hash no wired hasher reads | `hashPrefix` — never the hash |
86
- | `USER_INACTIVE` | `UserInactiveError` | 403 | Deactivated; told only to someone who gave the right password | `userId` |
87
- | `TOKEN_UNKNOWN`, `TOKEN_SPENT`, `TOKEN_EXPIRED`, `TOKEN_STALE` | `TokenError` | 400 | See [e-mail flows](email-flows.md#what-a-token-refusal-means). For a second factor's challenge: sign in again | |
88
- | `CODE_INVALID` | `TokenError` | 401 | A second factor's code that does not match, or was already accepted — see [the second factor](second-factor.md#confirming-the-code-at-sign-in) | `attemptsLeft` from `confirm`: what the challenge has left, `0` once it is spent. None from `activate` |
86
+ | `USER_INACTIVE` | `UserInactiveError` | 403 | Deactivated; told only to someone who gave the right password, or the right code | `userId` |
87
+ | `TOKEN_UNKNOWN`, `TOKEN_SPENT`, `TOKEN_EXPIRED`, `TOKEN_STALE` | `TokenError` | 400 | See [e-mail flows](email-flows.md#what-a-token-refusal-means). For a second factor's challenge: sign in again; for a sign-in code's: request a new code — see [sign-in codes](sign-in-code.md#what-confirm-refuses) | `userId` on `TOKEN_STALE` |
88
+ | `CODE_INVALID` | `TokenError` | 401 | A one-time code that does not match: a second factor's, or one already accepted — see [the second factor](second-factor.md#confirming-the-code-at-sign-in) — or a sign-in code sent by e-mail — see [sign-in codes](sign-in-code.md#attempts) | `attemptsLeft` from either `confirm`: what the challenge has left, `0` once it is spent. None from `activate`. `userId` |
89
89
  | `SECOND_FACTOR_NOT_ENROLLED` | `SecondFactorError` | 409 | `activate` before `enroll`, or `confirm` after the factor was disabled | `userId` |
90
90
  | `SECOND_FACTOR_ACTIVE` | `SecondFactorError` | 409 | `enroll` or `activate` on a factor already active: `disable` it first | `userId` |
91
91
  | `INVALID_CURSOR` | `InvalidCursorError` | 400 | A cursor this store did not mint. Never a silent first page | |
@@ -206,8 +206,9 @@ interface SecondFactorRequired {
206
206
  }
207
207
  ```
208
208
 
209
- A user whose factor is active gets **no session from their password**: they
210
- get a **challenge**, which `secondFactor.confirm` redeems with a code. Anyone
209
+ A user whose factor is active gets **no session from their password** — nor
210
+ from a [code sent by e-mail](sign-in-code.md#a-second-factor-is-still-asked-for),
211
+ whose `confirm` answers the same union: they get a **challenge**, which `secondFactor.confirm` redeems with a code. Anyone
211
212
  else gets a session, as before. `signIn`'s refusals — `CREDENTIALS_INVALID`,
212
213
  `USER_INACTIVE` — are unchanged, and come before any challenge: a wrong
213
214
  password never tells anyone that a second factor exists.
@@ -562,6 +563,7 @@ with `STORE_FAILED`.
562
563
 
563
564
  ## See also
564
565
 
566
+ - [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
565
567
  - [Sessions](sessions.md) — the cookie `confirm`'s session is sent in, and `authenticatedAt`
566
568
  - [Errors](errors.md) — `CODE_INVALID`, `SECOND_FACTOR_NOT_ENROLLED`, `SECOND_FACTOR_ACTIVE` and their statuses
567
569
  - [Writing an adapter](adapters.md#a-users-password-and-second-factor) — what a store keeps of a factor
@@ -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
@@ -82,7 +82,7 @@ that cuts an emoji in half, say.
82
82
  | `password.login` | a field name | — | The field users sign in with: a **top-level, required string** field. A typo is a compile error |
83
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 |
84
84
  | `password.minLength` | integer ≥ 1 | `8` | Below it: `PASSWORD_TOO_SHORT` |
85
- | `email` | a field name | `'email'` | The field `verifyEmail` and `resetPassword` send to. Without one, those flows are absent from the type |
85
+ | `email` | a field name | `'email'` | The field `verifyEmail`, `resetPassword` and `signInCode` send to. Without one, those flows are absent from the type |
86
86
  | `session.lifespan` | `Duration` | `'7d'` | How long a session lives |
87
87
  | `session.renewAfter` | `Duration \| false` | `'1d'` | When `authenticate` slides the session. `false` for a fixed lifespan |
88
88
  | `schemaVersion` | string | `'1'` | Recorded on every user written. Bump it when the schema tightens |
@@ -94,6 +94,7 @@ that cuts an emoji in half, say.
94
94
  | `cookie` | `CookieConfig` | strict | See [sessions](sessions.md#the-cookie) |
95
95
  | `tokens.verifyEmail` | `Duration` | `'24h'` | How long a verification token lives |
96
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) |
97
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) |
98
99
 
99
100
  A `Duration` is `'500ms'`, `'30s'`, `'15m'`, `'8h'`, `'7d'`, or a number of
@@ -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 code. The **Not** column lists the words it replaces, so a search for
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,15 +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" alone — `code` is an error's code |
50
- | **one-time code** | Six digits a user types. For the [second factor](second-factor.md), computed by an authenticator app from the user's secret and never sent — what `activate` and `confirm` take. Sent by e-mail to sign in without a password is in progress, see [the roadmap](../roadmap.md) | "OTP", "PIN", "code" alone — `code` is an error's code |
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
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 or a one-time token, counted by `countAttempt` in the token's `attempts` before the code is checked. A challenge takes five | "try"; "retry" is a repeated write, never a guess |
53
- | **challenge** | What `signIn` answers instead of a session when the user's second factor is active: a secret, redeemed once with a code by `secondFactor.confirm` — which is what *confirm* means for it. Stored as its hash, as a one-time token of kind `secondFactor`: five minutes by default, and five attempts | "MFA token", "pending session", "ticket" |
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
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
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" |
56
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 | |
57
- | **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 | |
58
58
 
59
59
  ### Permissions
60
60
 
package/docs/roadmap.md CHANGED
@@ -5,14 +5,17 @@ dates here, and the version something shipped in is the only number.
5
5
 
6
6
  ## Now
7
7
 
8
- - **One-time codes** — a one-time token short enough to type, sent by e-mail
9
- to sign in without a password, or to confirm a sensitive action, issued
10
- and redeemed by `janus` like the verification and reset tokens today. The
11
- TOTP second factor shipped in v0.5.0, below, on the store port that
12
- shipped in v0.4.0.
8
+ Nothing between releases.
13
9
 
14
10
  ## Next
15
11
 
12
+ - **Confirm an action with an e-mailed code (step-up)** — a signed-in user
13
+ proves they still read their inbox before something a stolen session should
14
+ not do alone: changing the e-mail, disabling the second factor, deleting
15
+ the account. The same six digits, challenge and five attempts as a sign-in
16
+ code, bound to the session that asked rather than opening one — which
17
+ takes a token kind of its own, so a sign-in code can never confirm an
18
+ action nor an action's code sign anyone in.
16
19
  - **Recovery codes** — single-use codes for the TOTP second factor, so a
17
20
  user who loses their authenticator app can still sign in, without an
18
21
  operator resetting the account.
@@ -90,6 +93,14 @@ dates here, and the version something shipped in is the only number.
90
93
  The last ten, newest first, each with the version it came in. Everything
91
94
  before is in the [CHANGELOG](../CHANGELOG.md).
92
95
 
96
+ - **Sign in with a code sent by e-mail, v0.6.0** —
97
+ `auth.<type>.signInCode.request(email)` answers a six-digit code to send
98
+ and a challenge to keep with the visitor, or `null` for nobody — never
99
+ saying which; `signInCode.confirm(challenge, code)` marks the e-mail
100
+ verified and opens the session. On every user type with an e-mail, one
101
+ without a password included; an active second factor is still asked for.
102
+ A challenge lives ten minutes (`tokens.signInCode`) and takes five
103
+ attempts, and only the code's hash is stored, keyed by the challenge.
93
104
  - **A TOTP second factor, v0.5.0** — `janus({ secondFactor: { issuer, keys } })`
94
105
  and `auth.<type>.secondFactor`'s `enroll`, `activate`, `disable` and
95
106
  `confirm`, for every user type with a password; each secret sealed with
@@ -142,7 +153,3 @@ before is in the [CHANGELOG](../CHANGELOG.md).
142
153
  the session middleware, the cookie, a route guarded by a permission,
143
154
  `bindJanus()` to bind the instances once, and every error as its status.
144
155
  Its own 0.1.0, beside `@nxgt/janus` 0.1.3.
145
- - **`defineModel` completed by your editor** — subject types and subject sets
146
- in a relation, subject types in `fromField`, relations, permissions and
147
- arrows in a rule and in `when`; a wrong name's error lists the names it
148
- could have been. — v0.1.2