@nxgt/janus 0.4.0 → 0.5.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 (55) hide show
  1. package/README.md +102 -18
  2. package/dist/auth/config.d.ts +25 -1
  3. package/dist/auth/config.d.ts.map +1 -1
  4. package/dist/auth/context.d.ts.map +1 -1
  5. package/dist/auth/index.d.ts +3 -2
  6. package/dist/auth/index.d.ts.map +1 -1
  7. package/dist/auth/one-time.d.ts +39 -0
  8. package/dist/auth/one-time.d.ts.map +1 -0
  9. package/dist/auth/port/types.d.ts +10 -7
  10. package/dist/auth/port/types.d.ts.map +1 -1
  11. package/dist/auth/sealing.d.ts +39 -0
  12. package/dist/auth/sealing.d.ts.map +1 -0
  13. package/dist/auth/second-factor/challenge.d.ts +20 -0
  14. package/dist/auth/second-factor/challenge.d.ts.map +1 -0
  15. package/dist/auth/second-factor/factor.d.ts +30 -0
  16. package/dist/auth/second-factor/factor.d.ts.map +1 -0
  17. package/dist/auth/second-factor/flows.d.ts +19 -0
  18. package/dist/auth/second-factor/flows.d.ts.map +1 -0
  19. package/dist/auth/second-factor/lifecycle.d.ts +8 -0
  20. package/dist/auth/second-factor/lifecycle.d.ts.map +1 -0
  21. package/dist/auth/sessions.d.ts.map +1 -1
  22. package/dist/auth/totp.d.ts +36 -0
  23. package/dist/auth/totp.d.ts.map +1 -0
  24. package/dist/auth/types.d.ts +91 -7
  25. package/dist/auth/types.d.ts.map +1 -1
  26. package/dist/auth/users.d.ts +2 -2
  27. package/dist/auth/users.d.ts.map +1 -1
  28. package/dist/chunks/{index-qwfkhqkk.js → index-06vp9c5r.js} +12 -3
  29. package/dist/chunks/{index-qwfkhqkk.js.map → index-06vp9c5r.js.map} +3 -3
  30. package/dist/chunks/{index-53y1afjz.js → index-5vr13kkb.js} +2 -2
  31. package/dist/chunks/{index-mgh85djb.js → index-c4v27jfr.js} +2 -2
  32. package/dist/chunks/{index-thtyq7a9.js → index-gbwn2tts.js} +2 -2
  33. package/dist/conformance/cases/tokens.d.ts.map +1 -1
  34. package/dist/conformance/index.js +15 -4
  35. package/dist/conformance/index.js.map +3 -3
  36. package/dist/errors/janus-error.d.ts +28 -2
  37. package/dist/errors/janus-error.d.ts.map +1 -1
  38. package/dist/index.d.ts +1 -1
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +388 -41
  41. package/dist/index.js.map +14 -7
  42. package/dist/permissions/index.js +3 -3
  43. package/docs/README.md +1 -0
  44. package/docs/guide/adapters.md +12 -7
  45. package/docs/guide/errors.md +18 -6
  46. package/docs/guide/second-factor.md +569 -0
  47. package/docs/guide/sessions.md +18 -0
  48. package/docs/guide/users.md +8 -2
  49. package/docs/guide/vocabulary.md +6 -3
  50. package/docs/roadmap.md +26 -48
  51. package/docs/troubleshooting.md +260 -5
  52. package/package.json +1 -1
  53. /package/dist/chunks/{index-53y1afjz.js.map → index-5vr13kkb.js.map} +0 -0
  54. /package/dist/chunks/{index-mgh85djb.js.map → index-c4v27jfr.js.map} +0 -0
  55. /package/dist/chunks/{index-thtyq7a9.js.map → index-gbwn2tts.js.map} +0 -0
@@ -0,0 +1,569 @@
1
+ # The second factor
2
+
3
+ This page is for turning on a TOTP second factor — the six-digit codes of an
4
+ authenticator app — and wiring its four flows: enroll, activate, confirm at
5
+ sign-in, disable.
6
+
7
+ ```ts
8
+ import { z } from 'zod';
9
+ import { createMemoryStores, janus, scryptHasher } from '@nxgt/janus';
10
+
11
+ const auth = janus({
12
+ user: z.object({ email: z.email() }),
13
+ password: { login: 'email' },
14
+ store: createMemoryStores(),
15
+ hasher: scryptHasher(),
16
+ secondFactor: {
17
+ issuer: 'Example', // shown in the authenticator app beside the account
18
+ keys: [{ id: '2026-09', key: process.env.TOTP_KEY ?? '' }], // openssl rand -base64 32; unset, janus() refuses
19
+ },
20
+ });
21
+
22
+ const { user } = await auth.signUp({ email: 'ada@example.com', password: 'correct horse' });
23
+ const { uri } = await auth.secondFactor.enroll(user); // render uri as a QR code
24
+ await auth.secondFactor.activate(user, '123456'); // the first code the app shows
25
+
26
+ const result = await auth.signIn({ email: 'ada@example.com', password: 'correct horse' });
27
+ if (result.status === 'secondFactor') {
28
+ // no session yet: ask for a code, then
29
+ const signedIn = await auth.secondFactor.confirm(result.challenge, '654321');
30
+ }
31
+ ```
32
+
33
+ The words — **enrolled**, **active**, **challenge**, **attempt**, **step**,
34
+ **seal** — are defined in [the vocabulary](vocabulary.md#identities).
35
+
36
+ ## Configuration
37
+
38
+ | Option | Type | Default | Effect |
39
+ | --- | --- | --- | --- |
40
+ | `secondFactor.issuer` | string | — required | Your product's name. The authenticator app shows it beside the account, and it is the first half of the QR code's label |
41
+ | `secondFactor.keys` | `[SealingKey, ...SealingKey[]]` | — required | The **sealing keys**. Every TOTP secret is sealed with the first before a store sees it; every key opens. See [Rotating the keys](#rotating-the-keys) |
42
+ | `secondFactor.challenge` | `Duration` | `'5m'` | How long the challenge `signIn` answers waits for a code |
43
+
44
+ ```ts
45
+ interface SecondFactorConfig {
46
+ readonly issuer: string;
47
+ readonly keys: readonly [SealingKey, ...SealingKey[]];
48
+ readonly challenge?: Duration;
49
+ }
50
+
51
+ interface SealingKey {
52
+ readonly id: string; // letters, digits, _ and -, at most 64: written into every secret it seals
53
+ readonly key: string; // 32 random bytes, in base64 or base64url
54
+ }
55
+ ```
56
+
57
+ ### Making a key
58
+
59
+ ```sh
60
+ openssl rand -base64 32
61
+ ```
62
+
63
+ That prints 44 characters: 32 random bytes in base64, which is what `key`
64
+ takes. Keep it where your other secrets are — an environment variable, a
65
+ secret manager — and **never in the database it protects**: a dump that holds
66
+ the key holds every secret it sealed.
67
+
68
+ ```ts
69
+ import type { SealingKey } from '@nxgt/janus';
70
+
71
+ function sealingKey(id: string, variable: string): SealingKey {
72
+ const key = process.env[variable];
73
+ if (key === undefined) throw new Error(`${variable} is not set`);
74
+ return { id, key };
75
+ }
76
+
77
+ const auth = janus({
78
+ user: z.object({ email: z.email() }),
79
+ password: { login: 'email' },
80
+ store,
81
+ hasher: scryptHasher(),
82
+ secondFactor: {
83
+ issuer: 'Example',
84
+ keys: [sealingKey('2026-09', 'TOTP_KEY_2026_09')],
85
+ challenge: '3m',
86
+ },
87
+ });
88
+ ```
89
+
90
+ Name a key after when it was made, and never give two keys the same `id`: the
91
+ id is written into every secret the key seals, and it is how the right key is
92
+ found to open it.
93
+
94
+ ### What `janus()` refuses
95
+
96
+ A configuration that cannot seal is refused when `janus()` is called, with a
97
+ bare `TypeError` — a wiring mistake, never an answer to a request:
98
+
99
+ | Message | Cause |
100
+ | --- | --- |
101
+ | `janus: secondFactor.issuer must name your application — the authenticator app shows it beside the account` | `issuer` missing or blank |
102
+ | `janus: secondFactor.keys: expected at least one key — [{ id, key }], the first seals` | `keys` missing or empty |
103
+ | `janus: secondFactor.keys: every key needs an id of letters, digits, _ and -, at most 64 of them` | an `id` with a `.`, a space, or over 64 characters |
104
+ | `janus: secondFactor.keys: two keys have the id "<id>"` | a repeated `id` |
105
+ | `janus: secondFactor.keys: the key "<id>" is not 32 bytes in base64 — make one with openssl rand -base64 32` | a key of another length, or not base64 |
106
+ | `janus: secondFactor.challenge: …` | a `challenge` that is not a duration |
107
+
108
+ ### What turning it on changes
109
+
110
+ - **`signIn` answers `SignInResult`**, a union you switch on `status` — see
111
+ [Signing in](#signing-in-switch-on-status). This is the one change that
112
+ breaks a caller: `const { token } = await auth.signIn(…)` no longer
113
+ compiles.
114
+ - **`secondFactor` exists on every user type with a password** —
115
+ `auth.secondFactor` with one type, `clinic.staff.secondFactor` with several.
116
+ A type with no password signs nobody in, so it has none; neither does an
117
+ instance given no `secondFactor`. Both are compile errors.
118
+ - **Every user carries `hasSecondFactor`**: `true` once the factor is active.
119
+ A schema may not declare that field.
120
+
121
+ Without `secondFactor`, `signIn` answers a session as before, typed
122
+ `SignedIn`, which now carries `status: 'signedIn'` too.
123
+
124
+ ## The states of a factor
125
+
126
+ | State | `hasSecondFactor` | `signIn` answers | Reached by |
127
+ | --- | --- | --- | --- |
128
+ | none | `false` | a session | a new user; `disable` |
129
+ | **enrolled** — waiting for a first code | `false` | a session | `enroll` |
130
+ | **active** | `true` | a challenge | `activate`, with a code that matches |
131
+
132
+ Only an active factor is asked for. A user who scanned the QR code and never
133
+ typed a code signs in with their password alone.
134
+
135
+ ## Enrolling: the QR code
136
+
137
+ ```ts
138
+ const { secret, uri } = await auth.secondFactor.enroll(user);
139
+ // secret: 'JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP' — base32, for a user who types it in
140
+ // uri: 'otpauth://totp/Example:ada%40example.com?secret=…&issuer=Example&algorithm=SHA1&digits=6&period=30'
141
+ ```
142
+
143
+ `enroll` mints a secret, seals it onto the user, and answers it once. **Show
144
+ both, keep neither**: render `uri` as a QR code — with any QR library, in the
145
+ browser or on the server — and show `secret` beside it for a user who cannot
146
+ scan. No call answers the secret again. Send the answer with
147
+ `Cache-Control: no-store`, so no cache keeps it either.
148
+
149
+ ```ts
150
+ interface SecondFactorEnrolment {
151
+ readonly secret: string; // base32
152
+ readonly uri: string; // otpauth://totp/<issuer>:<login>?…
153
+ }
154
+ ```
155
+
156
+ The label is `issuer:login` — the login the user signs in with, so the app
157
+ shows `Example (ada@example.com)`. The codes are RFC 6238 as every
158
+ authenticator app reads them — HMAC-SHA-1, six digits, a thirty-second
159
+ **step** — and none of the three is configurable: several apps ignore
160
+ `algorithm` and `digits` in the URI, and would show codes the server never
161
+ accepts.
162
+
163
+ | Call | Answers | Rejects with |
164
+ | --- | --- | --- |
165
+ | `enroll(user)` on a user with no factor | `{ secret, uri }`; the factor is enrolled | |
166
+ | `enroll(user)` on an enrolled factor | a new secret: **it replaces the waiting one**, and the last QR code shown is the one that works | |
167
+ | `enroll(user)` on an active factor | | `SECOND_FACTOR_ACTIVE` — `disable` it first: enrolling again must not quietly switch it off |
168
+
169
+ `enroll` takes `{ ifVersion }` like every write.
170
+
171
+ ## Activating: the first code
172
+
173
+ ```ts
174
+ const active = await auth.secondFactor.activate(user, code); // the code the app shows now
175
+ active.hasSecondFactor; // true: from now on, signIn asks for a code
176
+ ```
177
+
178
+ `activate` proves the user's app holds the secret before anything depends on
179
+ it. Until it succeeds, the factor waits and `signIn` asks for nothing.
180
+
181
+ | Rejects with | When |
182
+ | --- | --- |
183
+ | `CODE_INVALID` | the code does not match, or is not six digits. The factor stays enrolled, and the user tries again. **No `attemptsLeft`**: `activate` is not a challenge, so it counts nothing — rate-limit it as you would any authenticated form |
184
+ | `SECOND_FACTOR_NOT_ENROLLED` | `enroll` was never called, or `disable` was called since |
185
+ | `SECOND_FACTOR_ACTIVE` | the factor is already active |
186
+
187
+ A code is accepted once. The one that activated the factor is spent, so the
188
+ user's next sign-in waits for the app's next code.
189
+
190
+ ## Signing in: switch on `status`
191
+
192
+ ```ts
193
+ type SignInResult<U> = SignedIn<U> | SecondFactorRequired;
194
+
195
+ interface SignedIn<U> {
196
+ readonly status: 'signedIn';
197
+ readonly user: U;
198
+ readonly session: Session;
199
+ readonly token: string;
200
+ }
201
+
202
+ interface SecondFactorRequired {
203
+ readonly status: 'secondFactor';
204
+ readonly challenge: string; // a secret, like a session token
205
+ readonly expiresAt: Date; // five minutes from now, by default
206
+ }
207
+ ```
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
211
+ else gets a session, as before. `signIn`'s refusals — `CREDENTIALS_INVALID`,
212
+ `USER_INACTIVE` — are unchanged, and come before any challenge: a wrong
213
+ password never tells anyone that a second factor exists.
214
+
215
+ ```ts
216
+ const result = await auth.signIn({ email, password });
217
+ switch (result.status) {
218
+ case 'signedIn':
219
+ // result.token, result.session: set the cookie, as before
220
+ break;
221
+ case 'secondFactor':
222
+ // result.challenge: keep it for the next request, and ask for a code
223
+ break;
224
+ }
225
+ ```
226
+
227
+ ### Where to keep the challenge
228
+
229
+ **The challenge is a secret.** With a password already proved, it is half of
230
+ a sign-in: whoever holds it needs only a code. Keep it where the visitor's
231
+ next request presents it, and nowhere else:
232
+
233
+ - **a short-lived cookie** — `HttpOnly`, `Secure`, `SameSite=Strict`, a
234
+ `Path` covering only the code route, and a `Max-Age` ending at `expiresAt`;
235
+ - or **the body of your code form** — a hidden field of the page that asks
236
+ for the code;
237
+ - or, for a bearer client, the body of the answer, which it sends back with
238
+ the code.
239
+
240
+ **Never in a URL** — a query string reaches server logs, proxies, the
241
+ browser's history and the `Referer` header — **and never in a log**. Log the
242
+ user's id if you need to; the challenge is stored only as its hash, like a
243
+ session token, so it cannot be recovered from the store either.
244
+
245
+ A challenge belongs to the user type that issued it: in a multi-type
246
+ application, confirm a staff member's challenge with
247
+ `clinic.staff.secondFactor.confirm`. Another type's `confirm` answers
248
+ `TOKEN_UNKNOWN`.
249
+
250
+ ## Confirming: the code at sign-in
251
+
252
+ ```ts
253
+ const signedIn = await auth.secondFactor.confirm(challenge, code);
254
+ // { status: 'signedIn', user, session, token }: the session is open
255
+ ```
256
+
257
+ `confirm` redeems the challenge with a code and opens the session — the same
258
+ `SignedIn` a sign-in without a second factor answers.
259
+
260
+ | Rejects with | When | What to do |
261
+ | --- | --- | --- |
262
+ | `CODE_INVALID`, with `attemptsLeft` | the code does not match, is not six digits, or was already accepted | ask again while `attemptsLeft > 0`; at `0` the challenge is spent: sign in again |
263
+ | `TOKEN_UNKNOWN` | no such challenge — a typo, another user type's, or one a store's TTL already dropped | sign in again |
264
+ | `TOKEN_SPENT` | the challenge already opened a session, or its attempts ran out | sign in again |
265
+ | `TOKEN_EXPIRED` | `expiresAt` has passed | sign in again |
266
+ | `USER_INACTIVE` | the user was deactivated since `signIn`. The challenge is spent | answer 403, as `signIn` would |
267
+ | `SECOND_FACTOR_NOT_ENROLLED` | the factor was disabled since `signIn`. The challenge is spent | sign in again: the password alone now opens a session |
268
+
269
+ ### Attempts
270
+
271
+ A challenge takes **five attempts**. Every call to `confirm` counts one, in
272
+ one write to the store, **before anything is checked** — a malformed code, a
273
+ code that races another: each costs an attempt. `attemptsLeft` counts down
274
+ `4, 3, 2, 1, 0`; the fifth wrong code spends the challenge, and the next call
275
+ is `TOKEN_SPENT`, even with the right code.
276
+
277
+ ```ts
278
+ import { TokenError } from '@nxgt/janus';
279
+
280
+ try {
281
+ await auth.secondFactor.confirm(challenge, code);
282
+ } catch (error) {
283
+ if (error instanceof TokenError && error.code === 'CODE_INVALID') {
284
+ error.attemptsLeft; // 4 after the first wrong code; 0 once the challenge is spent
285
+ }
286
+ throw error;
287
+ }
288
+ ```
289
+
290
+ Five attempts at a million values is a one-in-200,000 chance per password
291
+ guessed right. A new challenge takes a new sign-in, with the password, so the
292
+ attempts are bounded by your sign-in rate limit too.
293
+
294
+ ### Lifetime
295
+
296
+ A challenge lives `'5m'` unless `secondFactor.challenge` says otherwise, and
297
+ is `TOKEN_EXPIRED` after that. Five minutes is time to unlock a phone and
298
+ open an app; a longer window is a longer life for a stolen challenge.
299
+
300
+ ### Replay
301
+
302
+ A code is accepted **once**. Each accepted code records its step, and only a
303
+ later step counts after that — so a code seen over a shoulder, or replayed
304
+ against a new challenge, is `CODE_INVALID`. Of two `confirm` calls with the
305
+ same code at the same moment, one opens a session and the other is refused.
306
+
307
+ The app's code changes every thirty seconds, and the code of the step before
308
+ or after the current one is accepted too, for a phone whose clock drifted.
309
+
310
+ ## Disabling
311
+
312
+ ```ts
313
+ const user = await auth.secondFactor.disable(current.user);
314
+ user.hasSecondFactor; // false: signIn answers a session again
315
+ ```
316
+
317
+ `disable` removes the factor, active or enrolled, and answers the user. A
318
+ user without one is answered as they are. A challenge issued before is
319
+ refused afterwards, with `SECOND_FACTOR_NOT_ENROLLED`.
320
+
321
+ ### Asking before `enroll` and `disable` is your policy
322
+
323
+ `janus` checks nothing before `enroll` or `disable`: it does not know who is
324
+ calling, only which user is meant. **Whether the user must prove themselves
325
+ again first is the application's decision** — a support tool may disable a
326
+ factor for a user who lost their phone, while a user's own settings page
327
+ should not let a stolen session switch it off.
328
+
329
+ A common rule is a **recent sign-in**: the session was opened a few minutes
330
+ ago, so its holder just gave the password — and the code, when the factor is
331
+ active. `session.authenticatedAt` is when the session was opened, and renewal
332
+ does not move it:
333
+
334
+ ```ts
335
+ const RECENT = 5 * 60_000;
336
+
337
+ export async function disableSecondFactor(request: Request): Promise<Response> {
338
+ const current = await auth.authenticate(request);
339
+ if (current === null) return new Response(null, { status: 401 });
340
+ if (Date.now() - current.session.authenticatedAt.getTime() > RECENT) {
341
+ return Response.json({ error: 'signInAgain' }, { status: 403 });
342
+ }
343
+ await auth.secondFactor.disable(current.user);
344
+ return new Response(null, { status: 204 });
345
+ }
346
+ ```
347
+
348
+ Use the same check before `enroll`, before `changePassword`, and before
349
+ anything else a stolen session should not be able to do.
350
+
351
+ ## Rotating the keys
352
+
353
+ **The first key seals; every key opens.** A secret names the key that sealed
354
+ it (`v1.<key id>.…`), so a rotation is a change of order, not a migration:
355
+
356
+ 1. Make a new key, and add it **last**: every instance can now open what it
357
+ will seal, and none seals with it yet. Deploy that everywhere.
358
+
359
+ ```ts
360
+ keys: [sealingKey('2026-09', 'TOTP_KEY_2026_09'), sealingKey('2026-10', 'TOTP_KEY_2026_10')],
361
+ ```
362
+
363
+ 2. Move it **first**, and deploy again. It seals every new secret; the old
364
+ key still opens the others:
365
+
366
+ ```ts
367
+ keys: [sealingKey('2026-10', 'TOTP_KEY_2026_10'), sealingKey('2026-09', 'TOTP_KEY_2026_09')],
368
+ ```
369
+
370
+ Two deployments, because during a rolling one an instance still on the
371
+ old list would meet a secret sealed with a key it does not hold. With a
372
+ single instance, or one that stops before the next starts, step 1 can be
373
+ skipped.
374
+ 3. Wait. Every secret sealed under the old key is sealed again under the new
375
+ one **the next time a code is accepted** for it — at `activate` or
376
+ `confirm`. A user who does not sign in keeps the old sealing.
377
+ 4. Remove the old key only when no secret is sealed with it. Ask your
378
+ database, since the key's id is the second part of the stored secret:
379
+
380
+ ```ts
381
+ // MongoDB, with @nxgt/janus-mongo
382
+ await db.collection('users').countDocuments({ 'secondFactor.secret': { $regex: '^v1\\.2026-09\\.' } });
383
+ ```
384
+
385
+ ```sql
386
+ -- PostgreSQL, with @nxgt/janus-drizzle
387
+ select count(*) from users where second_factor_secret like 'v1.2026-09.%';
388
+ ```
389
+
390
+ Those users have not signed in since the rotation. Wait longer, or
391
+ `disable` their factor and have them enroll again.
392
+
393
+ A key removed too early, or changed under the same id, is a wiring mistake,
394
+ and it surfaces the next time one of those users signs in — as a bare
395
+ `TypeError`, a 500, never a `CODE_INVALID` that would blame the user:
396
+
397
+ ```
398
+ secondFactor.confirm: the secret is sealed with the key "2026-09", which secondFactor.keys no longer holds — keep a key until no secret is sealed with it
399
+ secondFactor.confirm: the secret does not open with the key "2026-09" — was that key changed under the same id, or the secret copied from another user?
400
+ ```
401
+
402
+ **What sealing protects.** A secret is sealed with AES-256-GCM, and the
403
+ user's id is bound into it: a dump of the users, without the keys, produces
404
+ no code, and a sealed secret copied onto another user does not open. It does
405
+ not protect a store whose application is compromised — that process holds
406
+ the keys.
407
+
408
+ ## Failing closed without keys
409
+
410
+ A user whose factor is active is **never signed in by a `janus()` given no
411
+ `secondFactor`**. The password is right, a code is due, and that instance
412
+ cannot check one — so `signIn` throws a bare `TypeError` rather than open a
413
+ session on the password alone:
414
+
415
+ ```
416
+ signIn: the user's second factor is active, and janus() was given no secondFactor — pass secondFactor: { issuer, keys }
417
+ ```
418
+
419
+ This bites an application that builds more than one `janus()` — a web server
420
+ and an admin tool, a worker, a script — and gives the keys to one of them.
421
+ **Give every instance that signs users in the same `secondFactor`**, built
422
+ once:
423
+
424
+ ```ts
425
+ // auth.ts: the one configuration every entry point imports
426
+ export const secondFactor = {
427
+ issuer: 'Example',
428
+ keys: [sealingKey('2026-10', 'TOTP_KEY_2026_10'), sealingKey('2026-09', 'TOTP_KEY_2026_09')],
429
+ } as const;
430
+ ```
431
+
432
+ The same holds for `enroll`, `activate` and `confirm`, which an instance with
433
+ no keys cannot call — for TypeScript they do not exist on it, and for a
434
+ JavaScript caller they throw the same `TypeError`.
435
+
436
+ ## A sign-in with a code, as routes
437
+
438
+ A fetch-style pair of handlers — the shape Bun and most frameworks hand you.
439
+ The challenge travels in a cookie scoped to the sign-in routes;
440
+ [`@nxgt/janus-hono`](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus-hono/docs/guide/routes.md#a-second-factor)
441
+ has the same in Hono.
442
+
443
+ ```ts
444
+ import { JanusError, TokenError } from '@nxgt/janus';
445
+
446
+ const CHALLENGE = 'sign-in-challenge';
447
+ const scope = 'Path=/sign-in; HttpOnly; Secure; SameSite=Strict';
448
+
449
+ export async function signIn(request: Request): Promise<Response> {
450
+ const { email, password } = (await request.json()) as { email: string; password: string };
451
+ const result = await auth.signIn({ email, password });
452
+
453
+ if (result.status === 'secondFactor') {
454
+ const maxAge = Math.floor((result.expiresAt.getTime() - Date.now()) / 1000);
455
+ return Response.json(
456
+ { next: 'code' },
457
+ { headers: { 'Set-Cookie': `${CHALLENGE}=${result.challenge}; Max-Age=${maxAge}; ${scope}` } },
458
+ );
459
+ }
460
+ return Response.json(
461
+ { id: result.user.id },
462
+ { headers: { 'Set-Cookie': auth.cookie.serialize(result.token, result.session) } },
463
+ );
464
+ }
465
+
466
+ export async function confirmSecondFactor(request: Request): Promise<Response> {
467
+ const { code } = (await request.json()) as { code: string };
468
+ const challenge = request.headers
469
+ .get('cookie')
470
+ ?.match(new RegExp(`(?:^|;\\s*)${CHALLENGE}=([^;]+)`))?.[1];
471
+ if (challenge === undefined) return Response.json({ code: 'TOKEN_UNKNOWN' }, { status: 400 });
472
+
473
+ try {
474
+ const signedIn = await auth.secondFactor.confirm(challenge, code);
475
+ const headers = new Headers();
476
+ headers.append('Set-Cookie', auth.cookie.serialize(signedIn.token, signedIn.session));
477
+ headers.append('Set-Cookie', `${CHALLENGE}=; Max-Age=0; ${scope}`);
478
+ return Response.json({ id: signedIn.user.id }, { headers });
479
+ } catch (error) {
480
+ if (error instanceof TokenError && error.code === 'CODE_INVALID') {
481
+ return Response.json({ code: error.code, attemptsLeft: error.attemptsLeft }, { status: 401 });
482
+ }
483
+ if (error instanceof JanusError && error.code !== 'STORE_FAILED') {
484
+ return Response.json({ code: error.code }, { status: 400 }); // sign in again
485
+ }
486
+ throw error; // STORE_FAILED: your 503
487
+ }
488
+ }
489
+ ```
490
+
491
+ The code route answers `attemptsLeft` so the form can say how many attempts are
492
+ left, and nothing else: which of the causes of `CODE_INVALID` it was — a wrong
493
+ code, a reused one — is not the visitor's business.
494
+
495
+ ## In a test
496
+
497
+ The codes come from the secret `enroll` answered and the clock — pass
498
+ `fixedClock` to `janus()`, and compute them as an authenticator app does:
499
+
500
+ ```ts
501
+ import { createHmac } from 'node:crypto';
502
+ import { expect, it } from 'bun:test';
503
+ import { z } from 'zod';
504
+ import { createMemoryStores, fixedClock, janus, scryptHasher } from '@nxgt/janus';
505
+
506
+ /** The code an authenticator app shows at `at`: RFC 6238, SHA-1, six digits, thirty seconds. */
507
+ function totp(base32: string, at: Date): string {
508
+ const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
509
+ let bits = '';
510
+ for (const char of base32) bits += alphabet.indexOf(char).toString(2).padStart(5, '0');
511
+ const key = Buffer.from((bits.match(/.{8}/g) ?? []).map((byte) => Number.parseInt(byte, 2)));
512
+ const counter = Buffer.alloc(8);
513
+ counter.writeBigUInt64BE(BigInt(Math.floor(at.getTime() / 30_000)));
514
+ const digest = createHmac('sha1', key).update(counter).digest();
515
+ const offset = (digest[19] as number) & 0x0f;
516
+ return String((digest.readUInt32BE(offset) & 0x7fffffff) % 1_000_000).padStart(6, '0');
517
+ }
518
+
519
+ it('asks for a code once the factor is active', async () => {
520
+ const clock = fixedClock(Date.UTC(2026, 0, 1));
521
+ const auth = janus({
522
+ user: z.object({ email: z.email() }),
523
+ password: { login: 'email' },
524
+ store: createMemoryStores(),
525
+ hasher: scryptHasher({ cost: 10 }), // fast in tests; keep the default in production
526
+ clock,
527
+ secondFactor: {
528
+ issuer: 'Test',
529
+ keys: [{ id: 'test', key: Buffer.alloc(32, 1).toString('base64') }],
530
+ },
531
+ });
532
+ const credentials = { email: 'ada@example.com', password: 'correct horse' };
533
+ const { user } = await auth.signUp(credentials);
534
+ const { secret } = await auth.secondFactor.enroll(user);
535
+ await auth.secondFactor.activate(user, totp(secret, clock.now()));
536
+ clock.advance(30_000); // activation spent this step's code
537
+
538
+ const result = await auth.signIn(credentials);
539
+ if (result.status !== 'secondFactor') throw new Error('expected a challenge');
540
+ const signedIn = await auth.secondFactor.confirm(result.challenge, totp(secret, clock.now()));
541
+
542
+ expect(signedIn.user.hasSecondFactor).toBe(true);
543
+ });
544
+ ```
545
+
546
+ ## Signatures
547
+
548
+ ```ts
549
+ interface SecondFactorApi<U> {
550
+ readonly secondFactor: {
551
+ enroll(user: UserRef, options?: WriteOptions): Promise<SecondFactorEnrolment>;
552
+ activate(user: UserRef, code: string, options?: WriteOptions): Promise<U>;
553
+ disable(user: UserRef, options?: WriteOptions): Promise<U>;
554
+ confirm(challenge: string, code: string): Promise<SignedIn<U>>;
555
+ };
556
+ }
557
+ ```
558
+
559
+ `UserRef` is a user or its id; `WriteOptions` is `{ ifVersion? }`, as on
560
+ every write — see [Users](users.md#ifversion). Every call may also reject
561
+ with `STORE_FAILED`.
562
+
563
+ ## See also
564
+
565
+ - [Sessions](sessions.md) — the cookie `confirm`'s session is sent in, and `authenticatedAt`
566
+ - [Errors](errors.md) — `CODE_INVALID`, `SECOND_FACTOR_NOT_ENROLLED`, `SECOND_FACTOR_ACTIVE` and their statuses
567
+ - [Writing an adapter](adapters.md#a-users-password-and-second-factor) — what a store keeps of a factor
568
+ - [`@nxgt/janus-telemetry`](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus-telemetry/docs/guide/tracing.md) — the events a second factor writes
569
+ - [Troubleshooting](../troubleshooting.md) — by the message you see
@@ -145,6 +145,23 @@ export async function signOut(request: Request): Promise<Response> {
145
145
  }
146
146
  ```
147
147
 
148
+ **With `secondFactor` configured, switch on `status` first.** A user whose
149
+ second factor is active gets a challenge from `signIn`, not a session — no
150
+ `token`, no `session` — and the destructuring above no longer compiles:
151
+
152
+ ```ts
153
+ const result = await auth.signIn({ email, password });
154
+ if (result.status === 'secondFactor') {
155
+ // keep result.challenge for the next request — never in a URL — and ask for a code
156
+ return Response.json({ next: 'code' });
157
+ }
158
+ const { user, session, token } = result; // status 'signedIn': as above
159
+ ```
160
+
161
+ `secondFactor.confirm(challenge, code)` then answers the same `SignedIn`, and
162
+ its session is sent the same way. See
163
+ [the second factor](second-factor.md#signing-in-switch-on-status).
164
+
148
165
  `signOutEverywhere(user, { except })` revokes every standing session of a
149
166
  user, but the one named, and answers how many it revoked — "sign out
150
167
  everywhere else". An `except` that names no session of theirs — an unknown
@@ -209,5 +226,6 @@ cannot be replayed. `Session` is the stored record without that hash: `id`,
209
226
  ## See also
210
227
 
211
228
  - [Users](users.md) — `signUp`, `signIn`, the configuration
229
+ - [The second factor](second-factor.md) — the challenge `signIn` answers instead of a session
212
230
  - [E-mail flows](email-flows.md) — verification and password reset
213
231
  - [Errors](errors.md) — `STORE_FAILED`, `UNSUPPORTED` and the rest
@@ -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;
@@ -93,6 +94,7 @@ 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
+ | `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
98
 
97
99
  A `Duration` is `'500ms'`, `'30s'`, `'15m'`, `'8h'`, `'7d'`, or a number of
98
100
  milliseconds.
@@ -181,12 +183,16 @@ With a `password`, besides:
181
183
 
182
184
  | Method | Answers | Rejects with |
183
185
  | --- | --- | --- |
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` |
186
+ | `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` |
186
188
  | `findByLogin(login)` | the user, or `null`; the login is normalised first, and one holding a NUL or a lone surrogate is nobody's | |
187
189
  | `setPassword(user, password, { ifVersion? })` | the user — an admin's call | `PASSWORD_TOO_SHORT` |
188
190
  | `changePassword(user, { current, next }, { ifVersion? })` | the user — the user's own call | `CREDENTIALS_INVALID`, `PASSWORD_TOO_SHORT` |
189
191
 
192
+ With `secondFactor` configured, a type with a password also answers
193
+ `secondFactor.enroll`, `activate`, `disable` and `confirm` — see
194
+ [the second factor](second-factor.md).
195
+
190
196
  Every method may also reject with `STORE_FAILED`. A `user` argument is a user
191
197
  or its id (`UserRef = string | { id: string }`).
192
198
 
@@ -47,9 +47,12 @@ either finds this row.
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
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** | In progress, see [the roadmap](../roadmap.md): a one-time token short enough to type, sent by e-mail — or, for TOTP, computed by an authenticator app and never sent | "OTP", "PIN", "code" alone |
51
- | **second factor** | What a user proves at sign-in beyond their password: a TOTP secret their authenticator app holds, `UserRecord.secondFactor`. **Enrolled** when stored, **confirmed** once a first code matched (`confirmedAt`) | "2FA", "MFA", "OTP device" |
52
- | **attempt** | One code tried against a one-time token, counted by `countAttempt` in the token's `attempts` | "try"; "retry" is a repeated write, never a guess |
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 |
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" |
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
57
  | **e-mail flow** | `verifyEmail` or `resetPassword`: send a one-time token, then confirm it | |
55
58