@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.
- package/README.md +116 -23
- package/dist/auth/config.d.ts +3 -0
- package/dist/auth/config.d.ts.map +1 -1
- package/dist/auth/context.d.ts +12 -0
- package/dist/auth/context.d.ts.map +1 -1
- package/dist/auth/email-flows.d.ts +11 -0
- package/dist/auth/email-flows.d.ts.map +1 -0
- package/dist/auth/index.d.ts +1 -1
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/one-time.d.ts +66 -6
- package/dist/auth/one-time.d.ts.map +1 -1
- package/dist/auth/password-written.d.ts +24 -0
- package/dist/auth/password-written.d.ts.map +1 -0
- package/dist/auth/port/assert-stores.d.ts.map +1 -1
- package/dist/auth/port/types.d.ts +23 -1
- package/dist/auth/port/types.d.ts.map +1 -1
- package/dist/auth/second-factor/challenge.d.ts +0 -6
- package/dist/auth/second-factor/challenge.d.ts.map +1 -1
- package/dist/auth/second-factor/factor.d.ts +1 -4
- package/dist/auth/second-factor/factor.d.ts.map +1 -1
- package/dist/auth/second-factor/flows.d.ts +7 -3
- package/dist/auth/second-factor/flows.d.ts.map +1 -1
- package/dist/auth/second-factor/lifecycle.d.ts.map +1 -1
- package/dist/auth/sign-in-code.d.ts +20 -0
- package/dist/auth/sign-in-code.d.ts.map +1 -0
- package/dist/auth/types.d.ts +56 -1
- 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-gbwn2tts.js → index-c0jajeda.js} +12 -2
- package/dist/chunks/{index-gbwn2tts.js.map → index-c0jajeda.js.map} +3 -3
- package/dist/conformance/cases/outage.d.ts.map +1 -1
- package/dist/conformance/cases/tokens.d.ts.map +1 -1
- package/dist/conformance/index.js +73 -2
- package/dist/conformance/index.js.map +4 -4
- package/dist/index.js +253 -116
- package/dist/index.js.map +15 -12
- package/docs/README.md +2 -1
- package/docs/guide/adapters.md +56 -3
- package/docs/guide/email-flows.md +11 -1
- package/docs/guide/errors.md +3 -3
- package/docs/guide/second-factor.md +31 -4
- package/docs/guide/sign-in-code.md +489 -0
- package/docs/guide/users.md +5 -4
- package/docs/guide/vocabulary.md +7 -6
- package/docs/roadmap.md +28 -13
- package/docs/troubleshooting.md +193 -28
- 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
|
package/docs/guide/users.md
CHANGED
|
@@ -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 `
|
|
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
|
package/docs/guide/vocabulary.md
CHANGED
|
@@ -21,7 +21,7 @@ parseDuration('8h', 'session.lifespan'); // 28800000
|
|
|
21
21
|
## Words
|
|
22
22
|
|
|
23
23
|
One word per idea, the same in the README, these guides, the error messages
|
|
24
|
-
and the
|
|
24
|
+
and the source. The **Not** column lists the words it replaces, so a search for
|
|
25
25
|
either finds this row.
|
|
26
26
|
|
|
27
27
|
### The two sides
|
|
@@ -46,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"
|
|
50
|
-
| **one-time code** | Six digits a user types
|
|
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
|
|
53
|
-
| **challenge** |
|
|
52
|
+
| **attempt** | One code tried against a challenge, counted by `countAttempt` in the token's `attempts` before the code is checked. A challenge takes five, a second factor's or a sign-in code's | "try"; "retry" is a repeated write, never a guess |
|
|
53
|
+
| **challenge** | A secret the visitor keeps, redeemed once with a code — which is what *confirm* means for it. `signIn` answers one instead of a session when the user's second factor is active, for `secondFactor.confirm`; `signInCode.request` answers one beside the sign-in code, for `signInCode.confirm`. Stored as its hash, as a one-time token of kind `secondFactor` or `signInCode`: five minutes or ten by default, and five attempts | "MFA token", "pending session", "ticket" |
|
|
54
|
+
| **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
|
-
|
|
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
|