@nxgt/janus 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +116 -23
  2. package/dist/auth/config.d.ts +3 -0
  3. package/dist/auth/config.d.ts.map +1 -1
  4. package/dist/auth/context.d.ts +12 -0
  5. package/dist/auth/context.d.ts.map +1 -1
  6. package/dist/auth/email-flows.d.ts +11 -0
  7. package/dist/auth/email-flows.d.ts.map +1 -0
  8. package/dist/auth/index.d.ts +1 -1
  9. package/dist/auth/index.d.ts.map +1 -1
  10. package/dist/auth/one-time.d.ts +66 -6
  11. package/dist/auth/one-time.d.ts.map +1 -1
  12. package/dist/auth/password-written.d.ts +24 -0
  13. package/dist/auth/password-written.d.ts.map +1 -0
  14. package/dist/auth/port/assert-stores.d.ts.map +1 -1
  15. package/dist/auth/port/types.d.ts +23 -1
  16. package/dist/auth/port/types.d.ts.map +1 -1
  17. package/dist/auth/second-factor/challenge.d.ts +0 -6
  18. package/dist/auth/second-factor/challenge.d.ts.map +1 -1
  19. package/dist/auth/second-factor/factor.d.ts +1 -4
  20. package/dist/auth/second-factor/factor.d.ts.map +1 -1
  21. package/dist/auth/second-factor/flows.d.ts +7 -3
  22. package/dist/auth/second-factor/flows.d.ts.map +1 -1
  23. package/dist/auth/second-factor/lifecycle.d.ts.map +1 -1
  24. package/dist/auth/sign-in-code.d.ts +20 -0
  25. package/dist/auth/sign-in-code.d.ts.map +1 -0
  26. package/dist/auth/types.d.ts +56 -1
  27. package/dist/auth/types.d.ts.map +1 -1
  28. package/dist/auth/users.d.ts +2 -2
  29. package/dist/auth/users.d.ts.map +1 -1
  30. package/dist/chunks/{index-gbwn2tts.js → index-c0jajeda.js} +12 -2
  31. package/dist/chunks/{index-gbwn2tts.js.map → index-c0jajeda.js.map} +3 -3
  32. package/dist/conformance/cases/outage.d.ts.map +1 -1
  33. package/dist/conformance/cases/tokens.d.ts.map +1 -1
  34. package/dist/conformance/index.js +73 -2
  35. package/dist/conformance/index.js.map +4 -4
  36. package/dist/index.js +253 -116
  37. package/dist/index.js.map +15 -12
  38. package/docs/README.md +2 -1
  39. package/docs/guide/adapters.md +56 -3
  40. package/docs/guide/email-flows.md +11 -1
  41. package/docs/guide/errors.md +3 -3
  42. package/docs/guide/second-factor.md +31 -4
  43. package/docs/guide/sign-in-code.md +489 -0
  44. package/docs/guide/users.md +5 -4
  45. package/docs/guide/vocabulary.md +7 -6
  46. package/docs/roadmap.md +28 -13
  47. package/docs/troubleshooting.md +193 -28
  48. package/package.json +1 -1
@@ -0,0 +1,489 @@
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` and compares no code — but it has already cost one of
185
+ the challenge's five attempts, because the attempt is counted before the type
186
+ is known. The challenge is left for its own type, with one attempt fewer, and
187
+ the fifth such call spends it, as a fifth wrong code would.
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
+
225
+ **At most one code is live per user.** A `request` issues its code, then
226
+ spends every other challenge of the user, so the code in an earlier e-mail
227
+ answers `TOKEN_SPENT` — only the last one works:
228
+
229
+ ```ts
230
+ const first = await auth.signInCode.request(email);
231
+ const second = await auth.signInCode.request(email); // the visitor asked again
232
+ await auth.signInCode.confirm(first.challenge, first.code); // TOKEN_SPENT
233
+ await auth.signInCode.confirm(second.challenge, second.code); // signed in
234
+ ```
235
+
236
+ Requests that arrive at once cannot each keep a code: each spends the others'
237
+ once it issued its own, so at most one survives — sometimes none, and the
238
+ visitor asks again. Guesses therefore never run against two live challenges.
239
+
240
+ **Rate-limit `request` per address.** A new challenge takes only a new
241
+ `request`, and each one sends an e-mail and cancels the code before it:
242
+ without a limit, anyone who knows an address can fill its inbox, or keep
243
+ its owner from ever typing a code in time.
244
+
245
+ ### Lifetime
246
+
247
+ A challenge lives **ten minutes** unless `tokens.signInCode` says otherwise,
248
+ and is `TOKEN_EXPIRED` after that. Ten minutes is time for an e-mail to
249
+ arrive and be read; a longer window is a longer life for a stolen challenge.
250
+
251
+ ```ts
252
+ janus({ ..., tokens: { signInCode: '15m' } });
253
+ ```
254
+
255
+ ### The e-mail is verified
256
+
257
+ A user whose `emailVerified` was `false` has it `true` once `confirm`
258
+ succeeds, and their `version` moves: the code proves the address as a
259
+ verification link would. A user already verified is not written.
260
+
261
+ ### A second factor is still asked for
262
+
263
+ The code proves the e-mail, not the second factor. With `janus({ secondFactor })`,
264
+ a user whose factor is active gets **no session from the code**: `confirm`
265
+ answers a challenge, exactly as `signIn` does with a password, and
266
+ [`secondFactor.confirm`](second-factor.md#confirming-the-code-at-sign-in)
267
+ redeems it with the code of their authenticator app.
268
+
269
+ ```ts
270
+ const result = await auth.signInCode.confirm(challenge, code);
271
+ switch (result.status) {
272
+ case 'signedIn':
273
+ // result.token, result.session: set the cookie
274
+ break;
275
+ case 'secondFactor':
276
+ // result.challenge: a new challenge, for secondFactor.confirm — keep it as you kept this one
277
+ break;
278
+ }
279
+ ```
280
+
281
+ `confirm` answers `SignInResult` on a type whose `signIn` does — a type with
282
+ a password, in an instance given a `secondFactor` — and reading `token`
283
+ before narrowing on `status` is a compile error there. Anywhere else it
284
+ answers `SignedIn`.
285
+
286
+ ### What `confirm` refuses
287
+
288
+ | Rejects with | When | What to do |
289
+ | --- | --- | --- |
290
+ | `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 |
291
+ | `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, and its fifth spends it | request a new code |
292
+ | `TOKEN_SPENT` | the challenge already signed someone in, its attempts ran out, or a newer `request` for the same user spent it — only the last code sent works | request a new code, and use the latest e-mail |
293
+ | `TOKEN_EXPIRED` | `expiresAt` has passed | request a new code |
294
+ | `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 |
295
+ | `USER_INACTIVE` | the user was deactivated since the code was sent. The challenge is spent | answer 403 |
296
+ | `VERSION_CONFLICT` | rare: another write to the user landed while `confirm` marked the e-mail verified. The challenge is spent | request a new code |
297
+
298
+ Every refusal is a `TokenError` but `USER_INACTIVE`, a `UserInactiveError`;
299
+ with several user types the message starts with the type:
300
+ `patient.signInCode.confirm: …`. The `TOKEN_*` messages name the challenge —
301
+ `no such challenge`, `the challenge was already used`, `the challenge has
302
+ expired`. Every call may also reject with `STORE_FAILED`: your 503, never a
303
+ 401. [Troubleshooting](../troubleshooting.md#sign-in-codes) has each message
304
+ with its cause.
305
+
306
+ ## Passwordless types
307
+
308
+ A user type with an e-mail and **no `password`** signs in by code alone.
309
+ Create its users with `create` — `signUp` and `signIn` need a password, and
310
+ do not exist on it:
311
+
312
+ ```ts
313
+ import { z } from 'zod';
314
+ import { createMemoryStores, janus } from '@nxgt/janus';
315
+
316
+ const auth = janus({
317
+ user: z.object({ email: z.email(), name: z.string() }),
318
+ store: createMemoryStores(), // no password: no hasher needed
319
+ });
320
+
321
+ await auth.create({ email: 'ada@example.com', name: 'Ada' });
322
+
323
+ const issued = await auth.signInCode.request('ada@example.com');
324
+ if (issued !== null) {
325
+ const signedIn = await auth.signInCode.confirm(issued.challenge, issued.code);
326
+ signedIn.user.hasPassword; // false
327
+ }
328
+ ```
329
+
330
+ A passwordless type has no second factor either, so its `confirm` always
331
+ answers `SignedIn`, even in an instance given a `secondFactor`:
332
+
333
+ ```ts
334
+ const clinic = janus({
335
+ users: {
336
+ patient: { schema: z.object({ email: z.email() }), password: { login: 'email' } },
337
+ member: { schema: z.object({ email: z.email() }) },
338
+ },
339
+ store: createMemoryStores(),
340
+ hasher: scryptHasher(),
341
+ secondFactor: { issuer: 'Clinic', keys: [{ id: '2026-09', key: process.env.TOTP_KEY ?? '' }] },
342
+ });
343
+
344
+ const member = await clinic.member.signInCode.confirm(challenge, code);
345
+ member.token; // SignedIn: no status to narrow
346
+ const patient = await clinic.patient.signInCode.confirm(challenge, code);
347
+ if (patient.status === 'signedIn') patient.token; // SignInResult: narrow first
348
+ ```
349
+
350
+ A user of a type **with** a password can sign in by code too, whether or not
351
+ they ever set one: `create` a user with no password, and the code is how
352
+ they get in until `setPassword`.
353
+
354
+ ## Options
355
+
356
+ | Option | Type | Default | Effect |
357
+ | --- | --- | --- | --- |
358
+ | `tokens.signInCode` | `Duration` | `'10m'` | How long a challenge — and so its code — can be confirmed |
359
+ | `email` | a field name | `'email'` | The field the code is sent to, and looked up by, per type |
360
+
361
+ ```ts
362
+ janus({
363
+ user: z.object({ contact: z.email() }),
364
+ email: 'contact', // request() looks up, and answers, this field
365
+ store: createMemoryStores(),
366
+ tokens: { signInCode: '5m' },
367
+ });
368
+ ```
369
+
370
+ A `tokens.signInCode` that is not a duration is refused when `janus()` is
371
+ called, with a `TypeError`: `janus: tokens.signInCode: "<value>" is not a
372
+ duration; …`. The code is always six digits and a challenge always takes
373
+ five attempts; neither is configurable.
374
+
375
+ ## As routes
376
+
377
+ A fetch-style pair of handlers — the shape Bun and most frameworks hand
378
+ you. The challenge travels in a cookie scoped to the code route;
379
+ [`@nxgt/janus-hono`](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus-hono/docs/guide/routes.md#a-code-sent-by-e-mail)
380
+ has the same in Hono.
381
+
382
+ ```ts
383
+ import { randomBytes } from 'node:crypto';
384
+ import { JanusError, TokenError } from '@nxgt/janus';
385
+
386
+ const CHALLENGE = 'sign-in-code';
387
+ const scope = 'Path=/sign-in/email; HttpOnly; Secure; SameSite=Strict';
388
+
389
+ export async function requestCode(request: Request): Promise<Response> {
390
+ const { email } = (await request.json()) as { email: string };
391
+ const issued = await auth.signInCode.request(email);
392
+ if (issued !== null) {
393
+ void sendMail(issued.email, 'Your sign-in code', `Your code is ${issued.code}.`); // not awaited
394
+ }
395
+ // the same answer either way: a decoy challenge when nobody holds the e-mail
396
+ const challenge = issued?.challenge ?? randomBytes(32).toString('base64url');
397
+ return Response.json(
398
+ { next: 'code' },
399
+ { status: 202, headers: { 'Set-Cookie': `${CHALLENGE}=${challenge}; Max-Age=600; ${scope}` } },
400
+ );
401
+ }
402
+
403
+ export async function confirmCode(request: Request): Promise<Response> {
404
+ const { code } = (await request.json()) as { code: string };
405
+ const challenge = request.headers
406
+ .get('cookie')
407
+ ?.match(new RegExp(`(?:^|;\\s*)${CHALLENGE}=([^;]+)`))?.[1];
408
+ if (challenge === undefined) return Response.json({ code: 'TOKEN_UNKNOWN' }, { status: 400 });
409
+
410
+ try {
411
+ const signedIn = await auth.signInCode.confirm(challenge, code);
412
+ const headers = new Headers();
413
+ headers.append('Set-Cookie', auth.cookie.serialize(signedIn.token, signedIn.session));
414
+ headers.append('Set-Cookie', `${CHALLENGE}=; Max-Age=0; ${scope}`);
415
+ return Response.json({ id: signedIn.user.id }, { headers });
416
+ } catch (error) {
417
+ if (error instanceof TokenError && error.code === 'CODE_INVALID') {
418
+ return Response.json({ code: error.code, attemptsLeft: error.attemptsLeft }, { status: 401 });
419
+ }
420
+ if (error instanceof JanusError && error.code === 'USER_INACTIVE') {
421
+ return Response.json({ code: error.code }, { status: 403 });
422
+ }
423
+ if (error instanceof JanusError && error.code !== 'STORE_FAILED') {
424
+ return Response.json({ code: error.code }, { status: 400 }); // TOKEN_*: request a new code
425
+ }
426
+ throw error; // STORE_FAILED: your 503
427
+ }
428
+ }
429
+ ```
430
+
431
+ `Max-Age=600` is the default ten minutes, written out so the decoy and the
432
+ real cookie are the same header; derive it from `tokens.signInCode` if you
433
+ change that. A wrong code leaves the cookie in place, so the visitor types
434
+ it again. These routes answer `attemptsLeft`, so the code route tells a
435
+ decoy from a real challenge; where that matters, use
436
+ [one answer for every refusal](#requesting-a-code) instead. With a `secondFactor` configured, narrow `confirm`'s answer on
437
+ `status` before reading `token`, and hand the new challenge on to
438
+ [the second factor's route](second-factor.md#a-sign-in-with-a-code-as-routes).
439
+
440
+ ## In a test
441
+
442
+ No mailbox needed: `request` answers the code it would have sent.
443
+
444
+ ```ts
445
+ import { expect, it } from 'bun:test';
446
+ import { z } from 'zod';
447
+ import { createMemoryStores, fixedClock, janus } from '@nxgt/janus';
448
+
449
+ it('signs in with the e-mailed code, and not after ten minutes', async () => {
450
+ const clock = fixedClock(Date.UTC(2026, 0, 1));
451
+ const auth = janus({ user: z.object({ email: z.email() }), store: createMemoryStores(), clock });
452
+ await auth.create({ email: 'ada@example.com' });
453
+
454
+ const issued = await auth.signInCode.request('ada@example.com');
455
+ if (issued === null) throw new Error('expected a code');
456
+ expect((await auth.signInCode.confirm(issued.challenge, issued.code)).user.emailVerified).toBe(true);
457
+
458
+ const late = await auth.signInCode.request('ada@example.com');
459
+ if (late === null) throw new Error('expected a code');
460
+ clock.advance(10 * 60_000);
461
+ await expect(auth.signInCode.confirm(late.challenge, late.code)).rejects.toMatchObject({
462
+ code: 'TOKEN_EXPIRED',
463
+ });
464
+ });
465
+ ```
466
+
467
+ ## Signatures
468
+
469
+ ```ts
470
+ interface SignInCodeApi<U, Answer = SignedIn<U>> {
471
+ readonly signInCode: {
472
+ request(email: string): Promise<IssuedCode<U> | null>;
473
+ confirm(challenge: string, code: string): Promise<Answer>;
474
+ };
475
+ }
476
+ ```
477
+
478
+ `Answer` is `SignInResult<U>` on a type with a password in an instance given
479
+ a `secondFactor`, and `SignedIn<U>` everywhere else. `IssuedCode` and
480
+ `SignInCodeApi` are exported from `@nxgt/janus`, as types.
481
+
482
+ ## See also
483
+
484
+ - [The second factor](second-factor.md) — the challenge `confirm` answers for a user whose factor is active
485
+ - [E-mail verification and password reset](email-flows.md) — the other flows that send an e-mail, and `TOKEN_STALE`
486
+ - [Sessions](sessions.md) — the cookie the session is sent in
487
+ - [Errors](errors.md) — every code and its status
488
+ - [`@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
489
+ - [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
@@ -184,10 +185,10 @@ With a `password`, besides:
184
185
  | Method | Answers | Rejects with |
185
186
  | --- | --- | --- |
186
187
  | `signUp(fields & { password })` | `{ status: 'signedIn', user, session, token }` | `USER_INVALID`, `PASSWORD_TOO_SHORT`, `LOGIN_TAKEN` |
187
- | `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` |
188
+ | `signIn({ [login]: string, password })` | `{ status: 'signedIn', user, session, token }` — or, with `secondFactor` configured and the user's factor active, `{ status: 'secondFactor', challenge, expiresAt, userId }`: switch on `status` | `CREDENTIALS_INVALID`, `USER_INACTIVE`, `HASH_UNSUPPORTED` |
188
189
  | `findByLogin(login)` | the user, or `null`; the login is normalised first, and one holding a NUL or a lone surrogate is nobody's | |
189
- | `setPassword(user, password, { ifVersion? })` | the user — an admin's call | `PASSWORD_TOO_SHORT` |
190
- | `changePassword(user, { current, next }, { ifVersion? })` | the user — the user's own call | `CREDENTIALS_INVALID`, `PASSWORD_TOO_SHORT` |
190
+ | `setPassword(user, password, { ifVersion? })` | the user — an admin's call; spends the user's second-factor challenges still waiting, and signs nobody out | `PASSWORD_TOO_SHORT` |
191
+ | `changePassword(user, { current, next }, { ifVersion? })` | the user — the user's own call; spends the user's second-factor challenges still waiting | `CREDENTIALS_INVALID`, `PASSWORD_TOO_SHORT` |
191
192
 
192
193
  With `secondFactor` configured, a type with a password also answers
193
194
  `secondFactor.enroll`, `activate`, `disable` and `confirm` — see
@@ -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,16 @@ 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
+ | **spent** | A one-time token or a challenge that can no longer be redeemed — `spentAt` set, `TOKEN_SPENT`, and never unset. Spent by its redemption, by its fifth attempt (a wrong code, or a call through another user type's API), by a redemption after it expired, by a refusal that ends it (`USER_INACTIVE`, `TOKEN_STALE`, `SECOND_FACTOR_NOT_ENROLLED`), or by what ends it early: the next `signInCode.request` for the same user (at most one sign-in code is live at a time), or a password written by `resetPassword.confirm`, `setPassword` or `changePassword` (every second-factor challenge left waiting) | "used up", "invalidated"; "consumed" only in `consumeToken`'s name; "revoked" is a session's |
54
55
  | **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
56
  | **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
57
  | **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 | |
58
+ | **e-mail flow** | `verifyEmail` or `resetPassword`: send a one-time token, then confirm it. `signInCode` sends a one-time code instead | |
58
59
 
59
60
  ### Permissions
60
61
 
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,26 @@ 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
+ - **At most one sign-in code live per user, and challenges that end when
97
+ they should, v0.7.0** — `signInCode.request` spends the codes sent before,
98
+ even when requests race, so only the last e-mail's works; writing a
99
+ password spends the second-factor challenges left waiting, and a sign-in
100
+ still running when it lands is refused; another user type's `confirm` spends a challenge
101
+ at its fifth attempt; `verifyEmail.confirm` and `resetPassword.confirm`
102
+ check the e-mail again on the record they write. `SecondFactorRequired`
103
+ carries `userId`, for logs and rate limits. For adapters:
104
+ `TokenStore.spendUserTokens(userId, kind, at, except?)`, with four new conformance
105
+ cases, implemented in `@nxgt/janus-drizzle`, `@nxgt/janus-mongo` and
106
+ `@nxgt/janus-redis` — and the port now says a read sees every write that
107
+ completed before it: never a secondary or a read replica.
108
+ - **Sign in with a code sent by e-mail, v0.6.0** —
109
+ `auth.<type>.signInCode.request(email)` answers a six-digit code to send
110
+ and a challenge to keep with the visitor, or `null` for nobody — never
111
+ saying which; `signInCode.confirm(challenge, code)` marks the e-mail
112
+ verified and opens the session. On every user type with an e-mail, one
113
+ without a password included; an active second factor is still asked for.
114
+ A challenge lives ten minutes (`tokens.signInCode`) and takes five
115
+ attempts, and only the code's hash is stored, keyed by the challenge.
93
116
  - **A TOTP second factor, v0.5.0** — `janus({ secondFactor: { issuer, keys } })`
94
117
  and `auth.<type>.secondFactor`'s `enroll`, `activate`, `disable` and
95
118
  `confirm`, for every user type with a password; each secret sealed with
@@ -138,11 +161,3 @@ before is in the [CHANGELOG](../CHANGELOG.md).
138
161
  - **The conformance suite accepts a store with its own expiry** — a store
139
162
  that drops a lapsed session at once, as a Redis TTL does, passes
140
163
  `sessions.deleteUser`; one that still holds it must count it. — v0.2.0
141
- - **A Hono integration** — [`@nxgt/janus-hono`](https://www.npmjs.com/package/@nxgt/janus-hono):
142
- the session middleware, the cookie, a route guarded by a permission,
143
- `bindJanus()` to bind the instances once, and every error as its status.
144
- 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