@nxgt/janus 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +159 -20
- package/dist/auth/config.d.ts +28 -1
- package/dist/auth/config.d.ts.map +1 -1
- package/dist/auth/context.d.ts +6 -0
- package/dist/auth/context.d.ts.map +1 -1
- package/dist/auth/index.d.ts +3 -2
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/one-time.d.ts +92 -0
- package/dist/auth/one-time.d.ts.map +1 -0
- package/dist/auth/port/types.d.ts +10 -7
- package/dist/auth/port/types.d.ts.map +1 -1
- package/dist/auth/sealing.d.ts +39 -0
- package/dist/auth/sealing.d.ts.map +1 -0
- package/dist/auth/second-factor/challenge.d.ts +14 -0
- package/dist/auth/second-factor/challenge.d.ts.map +1 -0
- package/dist/auth/second-factor/factor.d.ts +27 -0
- package/dist/auth/second-factor/factor.d.ts.map +1 -0
- package/dist/auth/second-factor/flows.d.ts +23 -0
- package/dist/auth/second-factor/flows.d.ts.map +1 -0
- package/dist/auth/second-factor/lifecycle.d.ts +8 -0
- package/dist/auth/second-factor/lifecycle.d.ts.map +1 -0
- package/dist/auth/sessions.d.ts.map +1 -1
- package/dist/auth/sign-in-code.d.ts +17 -0
- package/dist/auth/sign-in-code.d.ts.map +1 -0
- package/dist/auth/totp.d.ts +36 -0
- package/dist/auth/totp.d.ts.map +1 -0
- package/dist/auth/types.d.ts +140 -7
- package/dist/auth/types.d.ts.map +1 -1
- package/dist/auth/users.d.ts +2 -2
- package/dist/auth/users.d.ts.map +1 -1
- package/dist/chunks/{index-qwfkhqkk.js → index-06vp9c5r.js} +12 -3
- package/dist/chunks/{index-qwfkhqkk.js.map → index-06vp9c5r.js.map} +3 -3
- package/dist/chunks/{index-53y1afjz.js → index-5vr13kkb.js} +2 -2
- package/dist/chunks/{index-mgh85djb.js → index-c4v27jfr.js} +2 -2
- package/dist/chunks/{index-thtyq7a9.js → index-gbwn2tts.js} +2 -2
- package/dist/conformance/cases/tokens.d.ts.map +1 -1
- package/dist/conformance/index.js +15 -4
- package/dist/conformance/index.js.map +3 -3
- package/dist/errors/janus-error.d.ts +28 -2
- package/dist/errors/janus-error.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +478 -51
- package/dist/index.js.map +15 -7
- package/dist/permissions/index.js +3 -3
- package/docs/README.md +2 -0
- package/docs/guide/adapters.md +12 -7
- package/docs/guide/email-flows.md +4 -0
- package/docs/guide/errors.md +19 -7
- package/docs/guide/second-factor.md +571 -0
- package/docs/guide/sessions.md +18 -0
- package/docs/guide/sign-in-code.md +471 -0
- package/docs/guide/users.md +10 -3
- package/docs/guide/vocabulary.md +9 -6
- package/docs/roadmap.md +39 -54
- package/docs/troubleshooting.md +414 -5
- package/package.json +1 -1
- /package/dist/chunks/{index-53y1afjz.js.map → index-5vr13kkb.js.map} +0 -0
- /package/dist/chunks/{index-mgh85djb.js.map → index-c4v27jfr.js.map} +0 -0
- /package/dist/chunks/{index-thtyq7a9.js.map → index-gbwn2tts.js.map} +0 -0
|
@@ -0,0 +1,571 @@
|
|
|
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** — 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
|
|
212
|
+
else gets a session, as before. `signIn`'s refusals — `CREDENTIALS_INVALID`,
|
|
213
|
+
`USER_INACTIVE` — are unchanged, and come before any challenge: a wrong
|
|
214
|
+
password never tells anyone that a second factor exists.
|
|
215
|
+
|
|
216
|
+
```ts
|
|
217
|
+
const result = await auth.signIn({ email, password });
|
|
218
|
+
switch (result.status) {
|
|
219
|
+
case 'signedIn':
|
|
220
|
+
// result.token, result.session: set the cookie, as before
|
|
221
|
+
break;
|
|
222
|
+
case 'secondFactor':
|
|
223
|
+
// result.challenge: keep it for the next request, and ask for a code
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
### Where to keep the challenge
|
|
229
|
+
|
|
230
|
+
**The challenge is a secret.** With a password already proved, it is half of
|
|
231
|
+
a sign-in: whoever holds it needs only a code. Keep it where the visitor's
|
|
232
|
+
next request presents it, and nowhere else:
|
|
233
|
+
|
|
234
|
+
- **a short-lived cookie** — `HttpOnly`, `Secure`, `SameSite=Strict`, a
|
|
235
|
+
`Path` covering only the code route, and a `Max-Age` ending at `expiresAt`;
|
|
236
|
+
- or **the body of your code form** — a hidden field of the page that asks
|
|
237
|
+
for the code;
|
|
238
|
+
- or, for a bearer client, the body of the answer, which it sends back with
|
|
239
|
+
the code.
|
|
240
|
+
|
|
241
|
+
**Never in a URL** — a query string reaches server logs, proxies, the
|
|
242
|
+
browser's history and the `Referer` header — **and never in a log**. Log the
|
|
243
|
+
user's id if you need to; the challenge is stored only as its hash, like a
|
|
244
|
+
session token, so it cannot be recovered from the store either.
|
|
245
|
+
|
|
246
|
+
A challenge belongs to the user type that issued it: in a multi-type
|
|
247
|
+
application, confirm a staff member's challenge with
|
|
248
|
+
`clinic.staff.secondFactor.confirm`. Another type's `confirm` answers
|
|
249
|
+
`TOKEN_UNKNOWN`.
|
|
250
|
+
|
|
251
|
+
## Confirming: the code at sign-in
|
|
252
|
+
|
|
253
|
+
```ts
|
|
254
|
+
const signedIn = await auth.secondFactor.confirm(challenge, code);
|
|
255
|
+
// { status: 'signedIn', user, session, token }: the session is open
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
`confirm` redeems the challenge with a code and opens the session — the same
|
|
259
|
+
`SignedIn` a sign-in without a second factor answers.
|
|
260
|
+
|
|
261
|
+
| Rejects with | When | What to do |
|
|
262
|
+
| --- | --- | --- |
|
|
263
|
+
| `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 |
|
|
264
|
+
| `TOKEN_UNKNOWN` | no such challenge — a typo, another user type's, or one a store's TTL already dropped | sign in again |
|
|
265
|
+
| `TOKEN_SPENT` | the challenge already opened a session, or its attempts ran out | sign in again |
|
|
266
|
+
| `TOKEN_EXPIRED` | `expiresAt` has passed | sign in again |
|
|
267
|
+
| `USER_INACTIVE` | the user was deactivated since `signIn`. The challenge is spent | answer 403, as `signIn` would |
|
|
268
|
+
| `SECOND_FACTOR_NOT_ENROLLED` | the factor was disabled since `signIn`. The challenge is spent | sign in again: the password alone now opens a session |
|
|
269
|
+
|
|
270
|
+
### Attempts
|
|
271
|
+
|
|
272
|
+
A challenge takes **five attempts**. Every call to `confirm` counts one, in
|
|
273
|
+
one write to the store, **before anything is checked** — a malformed code, a
|
|
274
|
+
code that races another: each costs an attempt. `attemptsLeft` counts down
|
|
275
|
+
`4, 3, 2, 1, 0`; the fifth wrong code spends the challenge, and the next call
|
|
276
|
+
is `TOKEN_SPENT`, even with the right code.
|
|
277
|
+
|
|
278
|
+
```ts
|
|
279
|
+
import { TokenError } from '@nxgt/janus';
|
|
280
|
+
|
|
281
|
+
try {
|
|
282
|
+
await auth.secondFactor.confirm(challenge, code);
|
|
283
|
+
} catch (error) {
|
|
284
|
+
if (error instanceof TokenError && error.code === 'CODE_INVALID') {
|
|
285
|
+
error.attemptsLeft; // 4 after the first wrong code; 0 once the challenge is spent
|
|
286
|
+
}
|
|
287
|
+
throw error;
|
|
288
|
+
}
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
Five attempts at a million values is a one-in-200,000 chance per password
|
|
292
|
+
guessed right. A new challenge takes a new sign-in, with the password, so the
|
|
293
|
+
attempts are bounded by your sign-in rate limit too.
|
|
294
|
+
|
|
295
|
+
### Lifetime
|
|
296
|
+
|
|
297
|
+
A challenge lives `'5m'` unless `secondFactor.challenge` says otherwise, and
|
|
298
|
+
is `TOKEN_EXPIRED` after that. Five minutes is time to unlock a phone and
|
|
299
|
+
open an app; a longer window is a longer life for a stolen challenge.
|
|
300
|
+
|
|
301
|
+
### Replay
|
|
302
|
+
|
|
303
|
+
A code is accepted **once**. Each accepted code records its step, and only a
|
|
304
|
+
later step counts after that — so a code seen over a shoulder, or replayed
|
|
305
|
+
against a new challenge, is `CODE_INVALID`. Of two `confirm` calls with the
|
|
306
|
+
same code at the same moment, one opens a session and the other is refused.
|
|
307
|
+
|
|
308
|
+
The app's code changes every thirty seconds, and the code of the step before
|
|
309
|
+
or after the current one is accepted too, for a phone whose clock drifted.
|
|
310
|
+
|
|
311
|
+
## Disabling
|
|
312
|
+
|
|
313
|
+
```ts
|
|
314
|
+
const user = await auth.secondFactor.disable(current.user);
|
|
315
|
+
user.hasSecondFactor; // false: signIn answers a session again
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
`disable` removes the factor, active or enrolled, and answers the user. A
|
|
319
|
+
user without one is answered as they are. A challenge issued before is
|
|
320
|
+
refused afterwards, with `SECOND_FACTOR_NOT_ENROLLED`.
|
|
321
|
+
|
|
322
|
+
### Asking before `enroll` and `disable` is your policy
|
|
323
|
+
|
|
324
|
+
`janus` checks nothing before `enroll` or `disable`: it does not know who is
|
|
325
|
+
calling, only which user is meant. **Whether the user must prove themselves
|
|
326
|
+
again first is the application's decision** — a support tool may disable a
|
|
327
|
+
factor for a user who lost their phone, while a user's own settings page
|
|
328
|
+
should not let a stolen session switch it off.
|
|
329
|
+
|
|
330
|
+
A common rule is a **recent sign-in**: the session was opened a few minutes
|
|
331
|
+
ago, so its holder just gave the password — and the code, when the factor is
|
|
332
|
+
active. `session.authenticatedAt` is when the session was opened, and renewal
|
|
333
|
+
does not move it:
|
|
334
|
+
|
|
335
|
+
```ts
|
|
336
|
+
const RECENT = 5 * 60_000;
|
|
337
|
+
|
|
338
|
+
export async function disableSecondFactor(request: Request): Promise<Response> {
|
|
339
|
+
const current = await auth.authenticate(request);
|
|
340
|
+
if (current === null) return new Response(null, { status: 401 });
|
|
341
|
+
if (Date.now() - current.session.authenticatedAt.getTime() > RECENT) {
|
|
342
|
+
return Response.json({ error: 'signInAgain' }, { status: 403 });
|
|
343
|
+
}
|
|
344
|
+
await auth.secondFactor.disable(current.user);
|
|
345
|
+
return new Response(null, { status: 204 });
|
|
346
|
+
}
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
Use the same check before `enroll`, before `changePassword`, and before
|
|
350
|
+
anything else a stolen session should not be able to do.
|
|
351
|
+
|
|
352
|
+
## Rotating the keys
|
|
353
|
+
|
|
354
|
+
**The first key seals; every key opens.** A secret names the key that sealed
|
|
355
|
+
it (`v1.<key id>.…`), so a rotation is a change of order, not a migration:
|
|
356
|
+
|
|
357
|
+
1. Make a new key, and add it **last**: every instance can now open what it
|
|
358
|
+
will seal, and none seals with it yet. Deploy that everywhere.
|
|
359
|
+
|
|
360
|
+
```ts
|
|
361
|
+
keys: [sealingKey('2026-09', 'TOTP_KEY_2026_09'), sealingKey('2026-10', 'TOTP_KEY_2026_10')],
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
2. Move it **first**, and deploy again. It seals every new secret; the old
|
|
365
|
+
key still opens the others:
|
|
366
|
+
|
|
367
|
+
```ts
|
|
368
|
+
keys: [sealingKey('2026-10', 'TOTP_KEY_2026_10'), sealingKey('2026-09', 'TOTP_KEY_2026_09')],
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
Two deployments, because during a rolling one an instance still on the
|
|
372
|
+
old list would meet a secret sealed with a key it does not hold. With a
|
|
373
|
+
single instance, or one that stops before the next starts, step 1 can be
|
|
374
|
+
skipped.
|
|
375
|
+
3. Wait. Every secret sealed under the old key is sealed again under the new
|
|
376
|
+
one **the next time a code is accepted** for it — at `activate` or
|
|
377
|
+
`confirm`. A user who does not sign in keeps the old sealing.
|
|
378
|
+
4. Remove the old key only when no secret is sealed with it. Ask your
|
|
379
|
+
database, since the key's id is the second part of the stored secret:
|
|
380
|
+
|
|
381
|
+
```ts
|
|
382
|
+
// MongoDB, with @nxgt/janus-mongo
|
|
383
|
+
await db.collection('users').countDocuments({ 'secondFactor.secret': { $regex: '^v1\\.2026-09\\.' } });
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
```sql
|
|
387
|
+
-- PostgreSQL, with @nxgt/janus-drizzle
|
|
388
|
+
select count(*) from users where second_factor_secret like 'v1.2026-09.%';
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
Those users have not signed in since the rotation. Wait longer, or
|
|
392
|
+
`disable` their factor and have them enroll again.
|
|
393
|
+
|
|
394
|
+
A key removed too early, or changed under the same id, is a wiring mistake,
|
|
395
|
+
and it surfaces the next time one of those users signs in — as a bare
|
|
396
|
+
`TypeError`, a 500, never a `CODE_INVALID` that would blame the user:
|
|
397
|
+
|
|
398
|
+
```
|
|
399
|
+
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
|
|
400
|
+
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?
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
**What sealing protects.** A secret is sealed with AES-256-GCM, and the
|
|
404
|
+
user's id is bound into it: a dump of the users, without the keys, produces
|
|
405
|
+
no code, and a sealed secret copied onto another user does not open. It does
|
|
406
|
+
not protect a store whose application is compromised — that process holds
|
|
407
|
+
the keys.
|
|
408
|
+
|
|
409
|
+
## Failing closed without keys
|
|
410
|
+
|
|
411
|
+
A user whose factor is active is **never signed in by a `janus()` given no
|
|
412
|
+
`secondFactor`**. The password is right, a code is due, and that instance
|
|
413
|
+
cannot check one — so `signIn` throws a bare `TypeError` rather than open a
|
|
414
|
+
session on the password alone:
|
|
415
|
+
|
|
416
|
+
```
|
|
417
|
+
signIn: the user's second factor is active, and janus() was given no secondFactor — pass secondFactor: { issuer, keys }
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
This bites an application that builds more than one `janus()` — a web server
|
|
421
|
+
and an admin tool, a worker, a script — and gives the keys to one of them.
|
|
422
|
+
**Give every instance that signs users in the same `secondFactor`**, built
|
|
423
|
+
once:
|
|
424
|
+
|
|
425
|
+
```ts
|
|
426
|
+
// auth.ts: the one configuration every entry point imports
|
|
427
|
+
export const secondFactor = {
|
|
428
|
+
issuer: 'Example',
|
|
429
|
+
keys: [sealingKey('2026-10', 'TOTP_KEY_2026_10'), sealingKey('2026-09', 'TOTP_KEY_2026_09')],
|
|
430
|
+
} as const;
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
The same holds for `enroll`, `activate` and `confirm`, which an instance with
|
|
434
|
+
no keys cannot call — for TypeScript they do not exist on it, and for a
|
|
435
|
+
JavaScript caller they throw the same `TypeError`.
|
|
436
|
+
|
|
437
|
+
## A sign-in with a code, as routes
|
|
438
|
+
|
|
439
|
+
A fetch-style pair of handlers — the shape Bun and most frameworks hand you.
|
|
440
|
+
The challenge travels in a cookie scoped to the sign-in routes;
|
|
441
|
+
[`@nxgt/janus-hono`](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus-hono/docs/guide/routes.md#a-second-factor)
|
|
442
|
+
has the same in Hono.
|
|
443
|
+
|
|
444
|
+
```ts
|
|
445
|
+
import { JanusError, TokenError } from '@nxgt/janus';
|
|
446
|
+
|
|
447
|
+
const CHALLENGE = 'sign-in-challenge';
|
|
448
|
+
const scope = 'Path=/sign-in; HttpOnly; Secure; SameSite=Strict';
|
|
449
|
+
|
|
450
|
+
export async function signIn(request: Request): Promise<Response> {
|
|
451
|
+
const { email, password } = (await request.json()) as { email: string; password: string };
|
|
452
|
+
const result = await auth.signIn({ email, password });
|
|
453
|
+
|
|
454
|
+
if (result.status === 'secondFactor') {
|
|
455
|
+
const maxAge = Math.floor((result.expiresAt.getTime() - Date.now()) / 1000);
|
|
456
|
+
return Response.json(
|
|
457
|
+
{ next: 'code' },
|
|
458
|
+
{ headers: { 'Set-Cookie': `${CHALLENGE}=${result.challenge}; Max-Age=${maxAge}; ${scope}` } },
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
return Response.json(
|
|
462
|
+
{ id: result.user.id },
|
|
463
|
+
{ headers: { 'Set-Cookie': auth.cookie.serialize(result.token, result.session) } },
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
export async function confirmSecondFactor(request: Request): Promise<Response> {
|
|
468
|
+
const { code } = (await request.json()) as { code: string };
|
|
469
|
+
const challenge = request.headers
|
|
470
|
+
.get('cookie')
|
|
471
|
+
?.match(new RegExp(`(?:^|;\\s*)${CHALLENGE}=([^;]+)`))?.[1];
|
|
472
|
+
if (challenge === undefined) return Response.json({ code: 'TOKEN_UNKNOWN' }, { status: 400 });
|
|
473
|
+
|
|
474
|
+
try {
|
|
475
|
+
const signedIn = await auth.secondFactor.confirm(challenge, code);
|
|
476
|
+
const headers = new Headers();
|
|
477
|
+
headers.append('Set-Cookie', auth.cookie.serialize(signedIn.token, signedIn.session));
|
|
478
|
+
headers.append('Set-Cookie', `${CHALLENGE}=; Max-Age=0; ${scope}`);
|
|
479
|
+
return Response.json({ id: signedIn.user.id }, { headers });
|
|
480
|
+
} catch (error) {
|
|
481
|
+
if (error instanceof TokenError && error.code === 'CODE_INVALID') {
|
|
482
|
+
return Response.json({ code: error.code, attemptsLeft: error.attemptsLeft }, { status: 401 });
|
|
483
|
+
}
|
|
484
|
+
if (error instanceof JanusError && error.code !== 'STORE_FAILED') {
|
|
485
|
+
return Response.json({ code: error.code }, { status: 400 }); // sign in again
|
|
486
|
+
}
|
|
487
|
+
throw error; // STORE_FAILED: your 503
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
The code route answers `attemptsLeft` so the form can say how many attempts are
|
|
493
|
+
left, and nothing else: which of the causes of `CODE_INVALID` it was — a wrong
|
|
494
|
+
code, a reused one — is not the visitor's business.
|
|
495
|
+
|
|
496
|
+
## In a test
|
|
497
|
+
|
|
498
|
+
The codes come from the secret `enroll` answered and the clock — pass
|
|
499
|
+
`fixedClock` to `janus()`, and compute them as an authenticator app does:
|
|
500
|
+
|
|
501
|
+
```ts
|
|
502
|
+
import { createHmac } from 'node:crypto';
|
|
503
|
+
import { expect, it } from 'bun:test';
|
|
504
|
+
import { z } from 'zod';
|
|
505
|
+
import { createMemoryStores, fixedClock, janus, scryptHasher } from '@nxgt/janus';
|
|
506
|
+
|
|
507
|
+
/** The code an authenticator app shows at `at`: RFC 6238, SHA-1, six digits, thirty seconds. */
|
|
508
|
+
function totp(base32: string, at: Date): string {
|
|
509
|
+
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
|
510
|
+
let bits = '';
|
|
511
|
+
for (const char of base32) bits += alphabet.indexOf(char).toString(2).padStart(5, '0');
|
|
512
|
+
const key = Buffer.from((bits.match(/.{8}/g) ?? []).map((byte) => Number.parseInt(byte, 2)));
|
|
513
|
+
const counter = Buffer.alloc(8);
|
|
514
|
+
counter.writeBigUInt64BE(BigInt(Math.floor(at.getTime() / 30_000)));
|
|
515
|
+
const digest = createHmac('sha1', key).update(counter).digest();
|
|
516
|
+
const offset = (digest[19] as number) & 0x0f;
|
|
517
|
+
return String((digest.readUInt32BE(offset) & 0x7fffffff) % 1_000_000).padStart(6, '0');
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
it('asks for a code once the factor is active', async () => {
|
|
521
|
+
const clock = fixedClock(Date.UTC(2026, 0, 1));
|
|
522
|
+
const auth = janus({
|
|
523
|
+
user: z.object({ email: z.email() }),
|
|
524
|
+
password: { login: 'email' },
|
|
525
|
+
store: createMemoryStores(),
|
|
526
|
+
hasher: scryptHasher({ cost: 10 }), // fast in tests; keep the default in production
|
|
527
|
+
clock,
|
|
528
|
+
secondFactor: {
|
|
529
|
+
issuer: 'Test',
|
|
530
|
+
keys: [{ id: 'test', key: Buffer.alloc(32, 1).toString('base64') }],
|
|
531
|
+
},
|
|
532
|
+
});
|
|
533
|
+
const credentials = { email: 'ada@example.com', password: 'correct horse' };
|
|
534
|
+
const { user } = await auth.signUp(credentials);
|
|
535
|
+
const { secret } = await auth.secondFactor.enroll(user);
|
|
536
|
+
await auth.secondFactor.activate(user, totp(secret, clock.now()));
|
|
537
|
+
clock.advance(30_000); // activation spent this step's code
|
|
538
|
+
|
|
539
|
+
const result = await auth.signIn(credentials);
|
|
540
|
+
if (result.status !== 'secondFactor') throw new Error('expected a challenge');
|
|
541
|
+
const signedIn = await auth.secondFactor.confirm(result.challenge, totp(secret, clock.now()));
|
|
542
|
+
|
|
543
|
+
expect(signedIn.user.hasSecondFactor).toBe(true);
|
|
544
|
+
});
|
|
545
|
+
```
|
|
546
|
+
|
|
547
|
+
## Signatures
|
|
548
|
+
|
|
549
|
+
```ts
|
|
550
|
+
interface SecondFactorApi<U> {
|
|
551
|
+
readonly secondFactor: {
|
|
552
|
+
enroll(user: UserRef, options?: WriteOptions): Promise<SecondFactorEnrolment>;
|
|
553
|
+
activate(user: UserRef, code: string, options?: WriteOptions): Promise<U>;
|
|
554
|
+
disable(user: UserRef, options?: WriteOptions): Promise<U>;
|
|
555
|
+
confirm(challenge: string, code: string): Promise<SignedIn<U>>;
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
`UserRef` is a user or its id; `WriteOptions` is `{ ifVersion? }`, as on
|
|
561
|
+
every write — see [Users](users.md#ifversion). Every call may also reject
|
|
562
|
+
with `STORE_FAILED`.
|
|
563
|
+
|
|
564
|
+
## See also
|
|
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
|
|
567
|
+
- [Sessions](sessions.md) — the cookie `confirm`'s session is sent in, and `authenticatedAt`
|
|
568
|
+
- [Errors](errors.md) — `CODE_INVALID`, `SECOND_FACTOR_NOT_ENROLLED`, `SECOND_FACTOR_ACTIVE` and their statuses
|
|
569
|
+
- [Writing an adapter](adapters.md#a-users-password-and-second-factor) — what a store keeps of a factor
|
|
570
|
+
- [`@nxgt/janus-telemetry`](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus-telemetry/docs/guide/tracing.md) — the events a second factor writes
|
|
571
|
+
- [Troubleshooting](../troubleshooting.md) — by the message you see
|
package/docs/guide/sessions.md
CHANGED
|
@@ -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
|