@nxgt/janus 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +70 -15
- package/dist/auth/config.d.ts +3 -0
- 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 +1 -1
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/one-time.d.ts +59 -6
- package/dist/auth/one-time.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 +17 -0
- package/dist/auth/sign-in-code.d.ts.map +1 -0
- package/dist/auth/types.d.ts +50 -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/index.js +115 -35
- package/dist/index.js.map +12 -11
- package/docs/README.md +1 -0
- package/docs/guide/email-flows.md +4 -0
- package/docs/guide/errors.md +3 -3
- package/docs/guide/second-factor.md +4 -2
- package/docs/guide/sign-in-code.md +471 -0
- package/docs/guide/users.md +2 -1
- package/docs/guide/vocabulary.md +6 -6
- package/docs/roadmap.md +16 -9
- package/docs/troubleshooting.md +164 -10
- package/package.json +1 -1
package/docs/README.md
CHANGED
|
@@ -14,6 +14,7 @@ below are defined once, in [Words](guide/vocabulary.md#words).
|
|
|
14
14
|
| [Users](guide/users.md) | You are wiring `janus()`, declaring one or several user types, or calling `create`, `update`, `list`, `delete` |
|
|
15
15
|
| [Sessions](guide/sessions.md) | You need to know who a request belongs to, set or clear the cookie, renew, sign out, or test expiry |
|
|
16
16
|
| [E-mail verification and password reset](guide/email-flows.md) | You are sending a verification or reset link, and handling what comes back |
|
|
17
|
+
| [Signing in with an e-mailed code](guide/sign-in-code.md) | You are signing users in with a six-digit code sent by e-mail — with no password, or beside one: requesting it without telling who exists, keeping the challenge, the attempts and the errors |
|
|
17
18
|
| [The second factor](guide/second-factor.md) | You are turning on TOTP codes: making and rotating the sealing keys, the QR code, `signIn`'s `status`, confirming a challenge, disabling |
|
|
18
19
|
| [Password hashing](guide/passwords.md) | You are choosing a hasher, raising its cost, moving to argon2id, or importing hashes from another system |
|
|
19
20
|
|
|
@@ -51,6 +51,9 @@ clinic.patient.verifyEmail.send; // exists: `email` names the field
|
|
|
51
51
|
clinic.staff.verifyEmail;
|
|
52
52
|
```
|
|
53
53
|
|
|
54
|
+
A type with an e-mail can also sign in with a code sent to it, with or
|
|
55
|
+
without a password — see [sign-in codes](sign-in-code.md).
|
|
56
|
+
|
|
54
57
|
## Options
|
|
55
58
|
|
|
56
59
|
| Option | Type | Default | Effect |
|
|
@@ -142,5 +145,6 @@ never the token, and no refusal's message contains it.
|
|
|
142
145
|
## See also
|
|
143
146
|
|
|
144
147
|
- [Users](users.md) — `email`, `update`, and the other per-type methods
|
|
148
|
+
- [Sign-in codes](sign-in-code.md) — the third flow that sends an e-mail: a code, not a link
|
|
145
149
|
- [Sessions](sessions.md) — `signOutEverywhere`, which `resetPassword.confirm` calls for you
|
|
146
150
|
- [Errors](errors.md) — every code, and the status it deserves
|
package/docs/guide/errors.md
CHANGED
|
@@ -83,9 +83,9 @@ that wired the library, so no handler needs to tell it apart.
|
|
|
83
83
|
| `PASSWORD_TOO_SHORT` | `CredentialError` | 400 | Below `password.minLength` | `minLength` — never the password |
|
|
84
84
|
| `CREDENTIALS_INVALID` | `CredentialError` | 401 | Unknown login, no password, or the wrong one — **one code for the three** | `reason`, for your logs only |
|
|
85
85
|
| `HASH_UNSUPPORTED` | `CredentialError` | 400 | A stored hash no wired hasher reads | `hashPrefix` — never the hash |
|
|
86
|
-
| `USER_INACTIVE` | `UserInactiveError` | 403 | Deactivated; told only to someone who gave the right password | `userId` |
|
|
87
|
-
| `TOKEN_UNKNOWN`, `TOKEN_SPENT`, `TOKEN_EXPIRED`, `TOKEN_STALE` | `TokenError` | 400 | See [e-mail flows](email-flows.md#what-a-token-refusal-means). For a second factor's challenge: sign in again | |
|
|
88
|
-
| `CODE_INVALID` | `TokenError` | 401 | A
|
|
86
|
+
| `USER_INACTIVE` | `UserInactiveError` | 403 | Deactivated; told only to someone who gave the right password, or the right code | `userId` |
|
|
87
|
+
| `TOKEN_UNKNOWN`, `TOKEN_SPENT`, `TOKEN_EXPIRED`, `TOKEN_STALE` | `TokenError` | 400 | See [e-mail flows](email-flows.md#what-a-token-refusal-means). For a second factor's challenge: sign in again; for a sign-in code's: request a new code — see [sign-in codes](sign-in-code.md#what-confirm-refuses) | `userId` on `TOKEN_STALE` |
|
|
88
|
+
| `CODE_INVALID` | `TokenError` | 401 | A one-time code that does not match: a second factor's, or one already accepted — see [the second factor](second-factor.md#confirming-the-code-at-sign-in) — or a sign-in code sent by e-mail — see [sign-in codes](sign-in-code.md#attempts) | `attemptsLeft` from either `confirm`: what the challenge has left, `0` once it is spent. None from `activate`. `userId` |
|
|
89
89
|
| `SECOND_FACTOR_NOT_ENROLLED` | `SecondFactorError` | 409 | `activate` before `enroll`, or `confirm` after the factor was disabled | `userId` |
|
|
90
90
|
| `SECOND_FACTOR_ACTIVE` | `SecondFactorError` | 409 | `enroll` or `activate` on a factor already active: `disable` it first | `userId` |
|
|
91
91
|
| `INVALID_CURSOR` | `InvalidCursorError` | 400 | A cursor this store did not mint. Never a silent first page | |
|
|
@@ -206,8 +206,9 @@ interface SecondFactorRequired {
|
|
|
206
206
|
}
|
|
207
207
|
```
|
|
208
208
|
|
|
209
|
-
A user whose factor is active gets **no session from their password
|
|
210
|
-
|
|
209
|
+
A user whose factor is active gets **no session from their password** — nor
|
|
210
|
+
from a [code sent by e-mail](sign-in-code.md#a-second-factor-is-still-asked-for),
|
|
211
|
+
whose `confirm` answers the same union: they get a **challenge**, which `secondFactor.confirm` redeems with a code. Anyone
|
|
211
212
|
else gets a session, as before. `signIn`'s refusals — `CREDENTIALS_INVALID`,
|
|
212
213
|
`USER_INACTIVE` — are unchanged, and come before any challenge: a wrong
|
|
213
214
|
password never tells anyone that a second factor exists.
|
|
@@ -562,6 +563,7 @@ with `STORE_FAILED`.
|
|
|
562
563
|
|
|
563
564
|
## See also
|
|
564
565
|
|
|
566
|
+
- [Sign-in codes](sign-in-code.md) — a sign-in by e-mailed code, which still asks for an active factor, with the same challenge
|
|
565
567
|
- [Sessions](sessions.md) — the cookie `confirm`'s session is sent in, and `authenticatedAt`
|
|
566
568
|
- [Errors](errors.md) — `CODE_INVALID`, `SECOND_FACTOR_NOT_ENROLLED`, `SECOND_FACTOR_ACTIVE` and their statuses
|
|
567
569
|
- [Writing an adapter](adapters.md#a-users-password-and-second-factor) — what a store keeps of a factor
|
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
# Signing in with an e-mailed code
|
|
2
|
+
|
|
3
|
+
This page is for signing a user in with a six-digit code sent to their
|
|
4
|
+
e-mail: no password needed, and the code proves the address. `janus` issues
|
|
5
|
+
and checks the code; **sending the e-mail is yours**.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
import { createMemoryStores, janus, scryptHasher } from '@nxgt/janus';
|
|
10
|
+
|
|
11
|
+
async function sendMail(to: string, subject: string, text: string): Promise<void> {
|
|
12
|
+
// your mailer
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const auth = janus({
|
|
16
|
+
user: z.object({ email: z.email() }),
|
|
17
|
+
password: { login: 'email' },
|
|
18
|
+
store: createMemoryStores(),
|
|
19
|
+
hasher: scryptHasher(),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const issued = await auth.signInCode.request('ada@example.com'); // IssuedCode | null
|
|
23
|
+
if (issued !== null) {
|
|
24
|
+
await sendMail(issued.email, 'Your sign-in code', `${issued.code} signs you in. It expires in 10 minutes.`);
|
|
25
|
+
|
|
26
|
+
// keep issued.challenge with the visitor; on their next request, with the code they typed:
|
|
27
|
+
const signedIn = await auth.signInCode.confirm(issued.challenge, issued.code);
|
|
28
|
+
signedIn.token; // a session, as signIn opens one
|
|
29
|
+
signedIn.user.emailVerified; // true: the code reached the inbox
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
A sign-in by code is two requests: one asks for a code and answers the same
|
|
34
|
+
page whoever asked, the other takes the code and opens the session. The
|
|
35
|
+
words — **one-time code**, **challenge**, **attempt** — are defined in
|
|
36
|
+
[the vocabulary](vocabulary.md#identities).
|
|
37
|
+
|
|
38
|
+
## Which types have it
|
|
39
|
+
|
|
40
|
+
`signInCode` exists on every user type with an e-mail — the field `email`
|
|
41
|
+
names, or a required string field called `email` — **with or without a
|
|
42
|
+
password**. A type with no e-mail has no `signInCode`: it is absent from its
|
|
43
|
+
type, not failing at run time.
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
const clinic = janus({
|
|
47
|
+
users: {
|
|
48
|
+
patient: { schema: z.object({ email: z.email() }), password: { login: 'email' } },
|
|
49
|
+
staff: { schema: z.object({ username: z.string() }), password: { login: 'username' } },
|
|
50
|
+
member: { schema: z.object({ email: z.email() }) }, // no password: signs in by code only
|
|
51
|
+
},
|
|
52
|
+
store: createMemoryStores(),
|
|
53
|
+
hasher: scryptHasher(),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
clinic.patient.signInCode.request; // exists
|
|
57
|
+
clinic.member.signInCode.request; // exists: see Passwordless types, below
|
|
58
|
+
// @ts-expect-error — staff have no e-mail to send a code to
|
|
59
|
+
clinic.staff.signInCode;
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Requesting a code
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
const issued = await auth.signInCode.request(email);
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`request` looks up the user of this type holding that e-mail — trimmed and
|
|
69
|
+
lowercased first, so `' ADA@example.com '` finds `ada@example.com` — and
|
|
70
|
+
issues a code for them:
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
interface IssuedCode<U> {
|
|
74
|
+
readonly code: string; // six digits, '042817': for the e-mail, and nowhere else
|
|
75
|
+
readonly challenge: string; // the secret the code is checked against: for the visitor, never the e-mail
|
|
76
|
+
readonly email: string; // the address to send the code to, as the user's field holds it
|
|
77
|
+
readonly expiresAt: Date; // ten minutes from now, by default
|
|
78
|
+
readonly user: U;
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
It answers **`null`** when nobody of this type holds that e-mail, when the
|
|
83
|
+
user is inactive, and when the value matches a login that only looks like an
|
|
84
|
+
e-mail — a username `ada@example.com` on a type whose e-mail is another
|
|
85
|
+
field. Nothing is issued, nothing is written.
|
|
86
|
+
|
|
87
|
+
**Never tell the visitor which.** Answer the same page, with the same
|
|
88
|
+
headers, whether `request` issued a code or not — "if an account uses that
|
|
89
|
+
address, we sent it a code". A different status, body or cookie is an
|
|
90
|
+
account-enumeration oracle: it tells anyone who types an address whether it
|
|
91
|
+
has an account. When the challenge travels in a cookie, set one on the
|
|
92
|
+
`null` path too, with a random value of the same shape, so the headers match
|
|
93
|
+
— confirming it is `TOKEN_UNKNOWN`, like any wrong challenge:
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
import { randomBytes } from 'node:crypto';
|
|
97
|
+
|
|
98
|
+
const issued = await auth.signInCode.request(email);
|
|
99
|
+
// 32 random bytes, base64url — the shape of a real challenge, which nothing will ever accept
|
|
100
|
+
const challenge = issued?.challenge ?? randomBytes(32).toString('base64url');
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The decoy makes **the request** answer the same; **the confirmation** still
|
|
104
|
+
tells, if it answers each refusal as it comes. Anyone can ask a code for an
|
|
105
|
+
address, then send a wrong one: a decoy's challenge is `TOKEN_UNKNOWN`, a
|
|
106
|
+
real one's is `CODE_INVALID` with `attemptsLeft`. When the addresses of your
|
|
107
|
+
users must stay secret, answer every refusal of the code route alike, and
|
|
108
|
+
give up `attemptsLeft`:
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
import { JanusError } from '@nxgt/janus';
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
const signedIn = await auth.signInCode.confirm(challenge, code);
|
|
115
|
+
// … set the session cookie, as below
|
|
116
|
+
} catch (error) {
|
|
117
|
+
if (error instanceof JanusError && error.code !== 'STORE_FAILED') {
|
|
118
|
+
// one answer for a wrong code, a spent or unknown challenge — a decoy's included
|
|
119
|
+
return Response.json({ code: 'CODE_INVALID' }, { status: 401 });
|
|
120
|
+
}
|
|
121
|
+
throw error; // STORE_FAILED: your 503
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Send the e-mail **after** answering, from a queue or a promise you do not
|
|
126
|
+
await: a request that sends an e-mail takes longer than one that does not,
|
|
127
|
+
and the time the answer takes would tell what its body does not. The store's
|
|
128
|
+
own latency — one write for a code issued, none for `null` — still tells a
|
|
129
|
+
patient observer; that limit is stated rather than denied.
|
|
130
|
+
|
|
131
|
+
Every `request` issues a **new** code with its own challenge; the earlier
|
|
132
|
+
ones stay valid until they are confirmed or expire. `janus` does not limit
|
|
133
|
+
how often a code is asked for: **rate-limit the request route** per address
|
|
134
|
+
and per client, as you would a password reset, or anyone can fill a user's
|
|
135
|
+
inbox.
|
|
136
|
+
|
|
137
|
+
## Sending the code by e-mail
|
|
138
|
+
|
|
139
|
+
Put **the code, and only the code**, in the e-mail — with its lifetime, so
|
|
140
|
+
the visitor knows how long they have:
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
if (issued !== null) {
|
|
144
|
+
const minutes = Math.round((issued.expiresAt.getTime() - Date.now()) / 60_000);
|
|
145
|
+
void sendMail(
|
|
146
|
+
issued.email,
|
|
147
|
+
'Your sign-in code',
|
|
148
|
+
`Your code is ${issued.code}. It expires in ${minutes} minutes.\n` +
|
|
149
|
+
'If you did not ask for it, ignore this e-mail: nobody can sign in without it.',
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
**No link, and no challenge.** A link carrying the challenge would put it in
|
|
155
|
+
the mailbox beside the code — whoever reads the e-mail would hold both
|
|
156
|
+
halves — and in every server log and proxy the link crosses. A sign-in link
|
|
157
|
+
is not this flow: the code is typed into the page that asked for it, so the
|
|
158
|
+
sign-in completes in the browser that started it.
|
|
159
|
+
|
|
160
|
+
Send it to `issued.email`, not to what the visitor typed: it is the address
|
|
161
|
+
the user's field holds, as they registered it.
|
|
162
|
+
|
|
163
|
+
## Keeping the challenge with the visitor
|
|
164
|
+
|
|
165
|
+
**The challenge is a secret.** The code alone signs nobody in: it is checked
|
|
166
|
+
against the challenge, and only its hash is stored, keyed by the challenge.
|
|
167
|
+
Whoever holds the challenge needs only the six digits, so keep it where the
|
|
168
|
+
visitor's next request presents it, and nowhere else:
|
|
169
|
+
|
|
170
|
+
- **a short-lived cookie** — `HttpOnly`, `Secure`, `SameSite=Strict`, a
|
|
171
|
+
`Path` covering only the code route, and a `Max-Age` ending at `expiresAt`;
|
|
172
|
+
- or **the body of the code form** — a hidden field of the page that asks
|
|
173
|
+
for the code;
|
|
174
|
+
- or, for a bearer client, the body of the answer, which it sends back with
|
|
175
|
+
the code.
|
|
176
|
+
|
|
177
|
+
**Never in the e-mail, never in a URL** — a query string reaches server logs,
|
|
178
|
+
proxies, the browser's history and the `Referer` header — **and never in a
|
|
179
|
+
log**. Log the user's id if you need to. The challenge is stored only as its
|
|
180
|
+
hash, like a session token, so the store cannot give it back either.
|
|
181
|
+
|
|
182
|
+
A challenge belongs to the user type that issued it: confirm a patient's
|
|
183
|
+
challenge with `clinic.patient.signInCode.confirm`. Another type's `confirm`
|
|
184
|
+
answers `TOKEN_UNKNOWN`, compares no code and spends nothing — but it has
|
|
185
|
+
already cost one of the challenge's five attempts, because the attempt is
|
|
186
|
+
counted before the type is known. The challenge is left for its own type,
|
|
187
|
+
with one attempt fewer.
|
|
188
|
+
|
|
189
|
+
## Confirming the code
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
const signedIn = await auth.signInCode.confirm(challenge, code);
|
|
193
|
+
// { status: 'signedIn', user, session, token }: the session is open
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
`confirm` checks the code against its challenge, spends the challenge, marks
|
|
197
|
+
the user's e-mail verified — the code reached the inbox — and opens a
|
|
198
|
+
session: the same `SignedIn` that `signIn` answers.
|
|
199
|
+
|
|
200
|
+
### Attempts
|
|
201
|
+
|
|
202
|
+
A challenge takes **five attempts**. Every call to `confirm` counts one, in
|
|
203
|
+
one write to the store, **before the code is compared** — a code that is not
|
|
204
|
+
six digits, a code that races another: each costs an attempt. `attemptsLeft`
|
|
205
|
+
counts down `4, 3, 2, 1, 0`; the fifth wrong code spends the challenge, and
|
|
206
|
+
the next call is `TOKEN_SPENT`, even with the right code.
|
|
207
|
+
|
|
208
|
+
```ts
|
|
209
|
+
import { TokenError } from '@nxgt/janus';
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
await auth.signInCode.confirm(challenge, code);
|
|
213
|
+
} catch (error) {
|
|
214
|
+
if (error instanceof TokenError && error.code === 'CODE_INVALID') {
|
|
215
|
+
error.attemptsLeft; // 4 after the first wrong code; 0 once the challenge is spent
|
|
216
|
+
}
|
|
217
|
+
throw error;
|
|
218
|
+
}
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
Five attempts at a million values is a one-in-200,000 chance per challenge.
|
|
222
|
+
Codes sent at once past the fifth attempt are all refused, the right one
|
|
223
|
+
included: the store counts, and nothing reads the count before writing it.
|
|
224
|
+
A new challenge takes a new `request` and a new e-mail — which is why that
|
|
225
|
+
route is the one to rate-limit.
|
|
226
|
+
|
|
227
|
+
### Lifetime
|
|
228
|
+
|
|
229
|
+
A challenge lives **ten minutes** unless `tokens.signInCode` says otherwise,
|
|
230
|
+
and is `TOKEN_EXPIRED` after that. Ten minutes is time for an e-mail to
|
|
231
|
+
arrive and be read; a longer window is a longer life for a stolen challenge.
|
|
232
|
+
|
|
233
|
+
```ts
|
|
234
|
+
janus({ ..., tokens: { signInCode: '15m' } });
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
### The e-mail is verified
|
|
238
|
+
|
|
239
|
+
A user whose `emailVerified` was `false` has it `true` once `confirm`
|
|
240
|
+
succeeds, and their `version` moves: the code proves the address as a
|
|
241
|
+
verification link would. A user already verified is not written.
|
|
242
|
+
|
|
243
|
+
### A second factor is still asked for
|
|
244
|
+
|
|
245
|
+
The code proves the e-mail, not the second factor. With `janus({ secondFactor })`,
|
|
246
|
+
a user whose factor is active gets **no session from the code**: `confirm`
|
|
247
|
+
answers a challenge, exactly as `signIn` does with a password, and
|
|
248
|
+
[`secondFactor.confirm`](second-factor.md#confirming-the-code-at-sign-in)
|
|
249
|
+
redeems it with the code of their authenticator app.
|
|
250
|
+
|
|
251
|
+
```ts
|
|
252
|
+
const result = await auth.signInCode.confirm(challenge, code);
|
|
253
|
+
switch (result.status) {
|
|
254
|
+
case 'signedIn':
|
|
255
|
+
// result.token, result.session: set the cookie
|
|
256
|
+
break;
|
|
257
|
+
case 'secondFactor':
|
|
258
|
+
// result.challenge: a new challenge, for secondFactor.confirm — keep it as you kept this one
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
`confirm` answers `SignInResult` on a type whose `signIn` does — a type with
|
|
264
|
+
a password, in an instance given a `secondFactor` — and reading `token`
|
|
265
|
+
before narrowing on `status` is a compile error there. Anywhere else it
|
|
266
|
+
answers `SignedIn`.
|
|
267
|
+
|
|
268
|
+
### What `confirm` refuses
|
|
269
|
+
|
|
270
|
+
| Rejects with | When | What to do |
|
|
271
|
+
| --- | --- | --- |
|
|
272
|
+
| `CODE_INVALID`, with `attemptsLeft` | the code does not match, or is not six digits. Message: `signInCode.confirm: the code does not match, or was already used` | ask again while `attemptsLeft > 0`; at `0` the challenge is spent: request a new code |
|
|
273
|
+
| `TOKEN_UNKNOWN` | no such challenge — a typo, another user type's, one whose user was deleted, or one a store's TTL already dropped. Another type's challenge still loses one of its attempts | request a new code |
|
|
274
|
+
| `TOKEN_SPENT` | the challenge already signed someone in, or its attempts ran out | request a new code |
|
|
275
|
+
| `TOKEN_EXPIRED` | `expiresAt` has passed | request a new code |
|
|
276
|
+
| `TOKEN_STALE` | the user changed their e-mail since the code was sent. Message: `signInCode.confirm: the code was sent to an e-mail the user no longer has`. The challenge is spent | request a new code, to the current address |
|
|
277
|
+
| `USER_INACTIVE` | the user was deactivated since the code was sent. The challenge is spent | answer 403 |
|
|
278
|
+
| `VERSION_CONFLICT` | rare: another write to the user landed while `confirm` marked the e-mail verified. The challenge is spent | request a new code |
|
|
279
|
+
|
|
280
|
+
Every refusal is a `TokenError` but `USER_INACTIVE`, a `UserInactiveError`;
|
|
281
|
+
with several user types the message starts with the type:
|
|
282
|
+
`patient.signInCode.confirm: …`. The `TOKEN_*` messages name the challenge —
|
|
283
|
+
`no such challenge`, `the challenge was already used`, `the challenge has
|
|
284
|
+
expired`. Every call may also reject with `STORE_FAILED`: your 503, never a
|
|
285
|
+
401. [Troubleshooting](../troubleshooting.md#sign-in-codes) has each message
|
|
286
|
+
with its cause.
|
|
287
|
+
|
|
288
|
+
## Passwordless types
|
|
289
|
+
|
|
290
|
+
A user type with an e-mail and **no `password`** signs in by code alone.
|
|
291
|
+
Create its users with `create` — `signUp` and `signIn` need a password, and
|
|
292
|
+
do not exist on it:
|
|
293
|
+
|
|
294
|
+
```ts
|
|
295
|
+
import { z } from 'zod';
|
|
296
|
+
import { createMemoryStores, janus } from '@nxgt/janus';
|
|
297
|
+
|
|
298
|
+
const auth = janus({
|
|
299
|
+
user: z.object({ email: z.email(), name: z.string() }),
|
|
300
|
+
store: createMemoryStores(), // no password: no hasher needed
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
await auth.create({ email: 'ada@example.com', name: 'Ada' });
|
|
304
|
+
|
|
305
|
+
const issued = await auth.signInCode.request('ada@example.com');
|
|
306
|
+
if (issued !== null) {
|
|
307
|
+
const signedIn = await auth.signInCode.confirm(issued.challenge, issued.code);
|
|
308
|
+
signedIn.user.hasPassword; // false
|
|
309
|
+
}
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
A passwordless type has no second factor either, so its `confirm` always
|
|
313
|
+
answers `SignedIn`, even in an instance given a `secondFactor`:
|
|
314
|
+
|
|
315
|
+
```ts
|
|
316
|
+
const clinic = janus({
|
|
317
|
+
users: {
|
|
318
|
+
patient: { schema: z.object({ email: z.email() }), password: { login: 'email' } },
|
|
319
|
+
member: { schema: z.object({ email: z.email() }) },
|
|
320
|
+
},
|
|
321
|
+
store: createMemoryStores(),
|
|
322
|
+
hasher: scryptHasher(),
|
|
323
|
+
secondFactor: { issuer: 'Clinic', keys: [{ id: '2026-09', key: process.env.TOTP_KEY ?? '' }] },
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
const member = await clinic.member.signInCode.confirm(challenge, code);
|
|
327
|
+
member.token; // SignedIn: no status to narrow
|
|
328
|
+
const patient = await clinic.patient.signInCode.confirm(challenge, code);
|
|
329
|
+
if (patient.status === 'signedIn') patient.token; // SignInResult: narrow first
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
A user of a type **with** a password can sign in by code too, whether or not
|
|
333
|
+
they ever set one: `create` a user with no password, and the code is how
|
|
334
|
+
they get in until `setPassword`.
|
|
335
|
+
|
|
336
|
+
## Options
|
|
337
|
+
|
|
338
|
+
| Option | Type | Default | Effect |
|
|
339
|
+
| --- | --- | --- | --- |
|
|
340
|
+
| `tokens.signInCode` | `Duration` | `'10m'` | How long a challenge — and so its code — can be confirmed |
|
|
341
|
+
| `email` | a field name | `'email'` | The field the code is sent to, and looked up by, per type |
|
|
342
|
+
|
|
343
|
+
```ts
|
|
344
|
+
janus({
|
|
345
|
+
user: z.object({ contact: z.email() }),
|
|
346
|
+
email: 'contact', // request() looks up, and answers, this field
|
|
347
|
+
store: createMemoryStores(),
|
|
348
|
+
tokens: { signInCode: '5m' },
|
|
349
|
+
});
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
A `tokens.signInCode` that is not a duration is refused when `janus()` is
|
|
353
|
+
called, with a `TypeError`: `janus: tokens.signInCode: "<value>" is not a
|
|
354
|
+
duration; …`. The code is always six digits and a challenge always takes
|
|
355
|
+
five attempts; neither is configurable.
|
|
356
|
+
|
|
357
|
+
## As routes
|
|
358
|
+
|
|
359
|
+
A fetch-style pair of handlers — the shape Bun and most frameworks hand
|
|
360
|
+
you. The challenge travels in a cookie scoped to the code route;
|
|
361
|
+
[`@nxgt/janus-hono`](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus-hono/docs/guide/routes.md#a-code-sent-by-e-mail)
|
|
362
|
+
has the same in Hono.
|
|
363
|
+
|
|
364
|
+
```ts
|
|
365
|
+
import { randomBytes } from 'node:crypto';
|
|
366
|
+
import { JanusError, TokenError } from '@nxgt/janus';
|
|
367
|
+
|
|
368
|
+
const CHALLENGE = 'sign-in-code';
|
|
369
|
+
const scope = 'Path=/sign-in/email; HttpOnly; Secure; SameSite=Strict';
|
|
370
|
+
|
|
371
|
+
export async function requestCode(request: Request): Promise<Response> {
|
|
372
|
+
const { email } = (await request.json()) as { email: string };
|
|
373
|
+
const issued = await auth.signInCode.request(email);
|
|
374
|
+
if (issued !== null) {
|
|
375
|
+
void sendMail(issued.email, 'Your sign-in code', `Your code is ${issued.code}.`); // not awaited
|
|
376
|
+
}
|
|
377
|
+
// the same answer either way: a decoy challenge when nobody holds the e-mail
|
|
378
|
+
const challenge = issued?.challenge ?? randomBytes(32).toString('base64url');
|
|
379
|
+
return Response.json(
|
|
380
|
+
{ next: 'code' },
|
|
381
|
+
{ status: 202, headers: { 'Set-Cookie': `${CHALLENGE}=${challenge}; Max-Age=600; ${scope}` } },
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export async function confirmCode(request: Request): Promise<Response> {
|
|
386
|
+
const { code } = (await request.json()) as { code: string };
|
|
387
|
+
const challenge = request.headers
|
|
388
|
+
.get('cookie')
|
|
389
|
+
?.match(new RegExp(`(?:^|;\\s*)${CHALLENGE}=([^;]+)`))?.[1];
|
|
390
|
+
if (challenge === undefined) return Response.json({ code: 'TOKEN_UNKNOWN' }, { status: 400 });
|
|
391
|
+
|
|
392
|
+
try {
|
|
393
|
+
const signedIn = await auth.signInCode.confirm(challenge, code);
|
|
394
|
+
const headers = new Headers();
|
|
395
|
+
headers.append('Set-Cookie', auth.cookie.serialize(signedIn.token, signedIn.session));
|
|
396
|
+
headers.append('Set-Cookie', `${CHALLENGE}=; Max-Age=0; ${scope}`);
|
|
397
|
+
return Response.json({ id: signedIn.user.id }, { headers });
|
|
398
|
+
} catch (error) {
|
|
399
|
+
if (error instanceof TokenError && error.code === 'CODE_INVALID') {
|
|
400
|
+
return Response.json({ code: error.code, attemptsLeft: error.attemptsLeft }, { status: 401 });
|
|
401
|
+
}
|
|
402
|
+
if (error instanceof JanusError && error.code === 'USER_INACTIVE') {
|
|
403
|
+
return Response.json({ code: error.code }, { status: 403 });
|
|
404
|
+
}
|
|
405
|
+
if (error instanceof JanusError && error.code !== 'STORE_FAILED') {
|
|
406
|
+
return Response.json({ code: error.code }, { status: 400 }); // TOKEN_*: request a new code
|
|
407
|
+
}
|
|
408
|
+
throw error; // STORE_FAILED: your 503
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
`Max-Age=600` is the default ten minutes, written out so the decoy and the
|
|
414
|
+
real cookie are the same header; derive it from `tokens.signInCode` if you
|
|
415
|
+
change that. A wrong code leaves the cookie in place, so the visitor types
|
|
416
|
+
it again. These routes answer `attemptsLeft`, so the code route tells a
|
|
417
|
+
decoy from a real challenge; where that matters, use
|
|
418
|
+
[one answer for every refusal](#requesting-a-code) instead. With a `secondFactor` configured, narrow `confirm`'s answer on
|
|
419
|
+
`status` before reading `token`, and hand the new challenge on to
|
|
420
|
+
[the second factor's route](second-factor.md#a-sign-in-with-a-code-as-routes).
|
|
421
|
+
|
|
422
|
+
## In a test
|
|
423
|
+
|
|
424
|
+
No mailbox needed: `request` answers the code it would have sent.
|
|
425
|
+
|
|
426
|
+
```ts
|
|
427
|
+
import { expect, it } from 'bun:test';
|
|
428
|
+
import { z } from 'zod';
|
|
429
|
+
import { createMemoryStores, fixedClock, janus } from '@nxgt/janus';
|
|
430
|
+
|
|
431
|
+
it('signs in with the e-mailed code, and not after ten minutes', async () => {
|
|
432
|
+
const clock = fixedClock(Date.UTC(2026, 0, 1));
|
|
433
|
+
const auth = janus({ user: z.object({ email: z.email() }), store: createMemoryStores(), clock });
|
|
434
|
+
await auth.create({ email: 'ada@example.com' });
|
|
435
|
+
|
|
436
|
+
const issued = await auth.signInCode.request('ada@example.com');
|
|
437
|
+
if (issued === null) throw new Error('expected a code');
|
|
438
|
+
expect((await auth.signInCode.confirm(issued.challenge, issued.code)).user.emailVerified).toBe(true);
|
|
439
|
+
|
|
440
|
+
const late = await auth.signInCode.request('ada@example.com');
|
|
441
|
+
if (late === null) throw new Error('expected a code');
|
|
442
|
+
clock.advance(10 * 60_000);
|
|
443
|
+
await expect(auth.signInCode.confirm(late.challenge, late.code)).rejects.toMatchObject({
|
|
444
|
+
code: 'TOKEN_EXPIRED',
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
## Signatures
|
|
450
|
+
|
|
451
|
+
```ts
|
|
452
|
+
interface SignInCodeApi<U, Answer = SignedIn<U>> {
|
|
453
|
+
readonly signInCode: {
|
|
454
|
+
request(email: string): Promise<IssuedCode<U> | null>;
|
|
455
|
+
confirm(challenge: string, code: string): Promise<Answer>;
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
`Answer` is `SignInResult<U>` on a type with a password in an instance given
|
|
461
|
+
a `secondFactor`, and `SignedIn<U>` everywhere else. `IssuedCode` and
|
|
462
|
+
`SignInCodeApi` are exported from `@nxgt/janus`, as types.
|
|
463
|
+
|
|
464
|
+
## See also
|
|
465
|
+
|
|
466
|
+
- [The second factor](second-factor.md) — the challenge `confirm` answers for a user whose factor is active
|
|
467
|
+
- [E-mail verification and password reset](email-flows.md) — the other flows that send an e-mail, and `TOKEN_STALE`
|
|
468
|
+
- [Sessions](sessions.md) — the cookie the session is sent in
|
|
469
|
+
- [Errors](errors.md) — every code and its status
|
|
470
|
+
- [`@nxgt/janus-telemetry`](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus-telemetry/docs/guide/tracing.md#a-code-sent-by-e-mail) — the events a sign-in by code writes
|
|
471
|
+
- [Troubleshooting](../troubleshooting.md#sign-in-codes) — by the message you see
|
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
|
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,15 @@ either finds this row.
|
|
|
46
46
|
| **lapsed**, **revoked**, **renewed** | A session past its `expiresAt`; one ended by a sign-out or a password reset; one whose `expiresAt` moved in passing (`renewAfter`) | "expired" — kept for `TOKEN_EXPIRED`, a one-time token |
|
|
47
47
|
| **anonymous** | A request that presents no session credential, or one that authenticates nobody: `authenticate` answers `null` | "unauthenticated", "guest", "logged out" |
|
|
48
48
|
| **bearer client** | A client that sends its session token as `Authorization: Bearer` rather than in a cookie | |
|
|
49
|
-
| **one-time token** | A single-use token sent by e-mail, for verification or a password reset | "code"
|
|
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
54
|
| **step** | The thirty-second period a TOTP code belongs to. `lastStep` is the step of the last code accepted, and only a later step counts: that is why a code is accepted once | "window", "period", "counter", in prose |
|
|
55
55
|
| **seal**, **sealing key** | To seal is to encrypt a TOTP secret — AES-256-GCM, bound to the user's id — before a store sees it. A sealing key is one `{ id, key }` of `secondFactor.keys`: the first seals, every one opens | "encrypt", "encryption key", "master key", "pepper" |
|
|
56
56
|
| **token** | Never alone in prose: a *session token* or a *one-time token*. The `tokens` store and the `TOKEN_*` codes are one-time tokens only | |
|
|
57
|
-
| **e-mail flow** | `verifyEmail` or `resetPassword`: send a one-time token, then confirm it | |
|
|
57
|
+
| **e-mail flow** | `verifyEmail` or `resetPassword`: send a one-time token, then confirm it. `signInCode` sends a one-time code instead | |
|
|
58
58
|
|
|
59
59
|
### Permissions
|
|
60
60
|
|
package/docs/roadmap.md
CHANGED
|
@@ -5,14 +5,17 @@ dates here, and the version something shipped in is the only number.
|
|
|
5
5
|
|
|
6
6
|
## Now
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
to sign in without a password, or to confirm a sensitive action, issued
|
|
10
|
-
and redeemed by `janus` like the verification and reset tokens today. The
|
|
11
|
-
TOTP second factor shipped in v0.5.0, below, on the store port that
|
|
12
|
-
shipped in v0.4.0.
|
|
8
|
+
Nothing between releases.
|
|
13
9
|
|
|
14
10
|
## Next
|
|
15
11
|
|
|
12
|
+
- **Confirm an action with an e-mailed code (step-up)** — a signed-in user
|
|
13
|
+
proves they still read their inbox before something a stolen session should
|
|
14
|
+
not do alone: changing the e-mail, disabling the second factor, deleting
|
|
15
|
+
the account. The same six digits, challenge and five attempts as a sign-in
|
|
16
|
+
code, bound to the session that asked rather than opening one — which
|
|
17
|
+
takes a token kind of its own, so a sign-in code can never confirm an
|
|
18
|
+
action nor an action's code sign anyone in.
|
|
16
19
|
- **Recovery codes** — single-use codes for the TOTP second factor, so a
|
|
17
20
|
user who loses their authenticator app can still sign in, without an
|
|
18
21
|
operator resetting the account.
|
|
@@ -90,6 +93,14 @@ dates here, and the version something shipped in is the only number.
|
|
|
90
93
|
The last ten, newest first, each with the version it came in. Everything
|
|
91
94
|
before is in the [CHANGELOG](../CHANGELOG.md).
|
|
92
95
|
|
|
96
|
+
- **Sign in with a code sent by e-mail, v0.6.0** —
|
|
97
|
+
`auth.<type>.signInCode.request(email)` answers a six-digit code to send
|
|
98
|
+
and a challenge to keep with the visitor, or `null` for nobody — never
|
|
99
|
+
saying which; `signInCode.confirm(challenge, code)` marks the e-mail
|
|
100
|
+
verified and opens the session. On every user type with an e-mail, one
|
|
101
|
+
without a password included; an active second factor is still asked for.
|
|
102
|
+
A challenge lives ten minutes (`tokens.signInCode`) and takes five
|
|
103
|
+
attempts, and only the code's hash is stored, keyed by the challenge.
|
|
93
104
|
- **A TOTP second factor, v0.5.0** — `janus({ secondFactor: { issuer, keys } })`
|
|
94
105
|
and `auth.<type>.secondFactor`'s `enroll`, `activate`, `disable` and
|
|
95
106
|
`confirm`, for every user type with a password; each secret sealed with
|
|
@@ -142,7 +153,3 @@ before is in the [CHANGELOG](../CHANGELOG.md).
|
|
|
142
153
|
the session middleware, the cookie, a route guarded by a permission,
|
|
143
154
|
`bindJanus()` to bind the instances once, and every error as its status.
|
|
144
155
|
Its own 0.1.0, beside `@nxgt/janus` 0.1.3.
|
|
145
|
-
- **`defineModel` completed by your editor** — subject types and subject sets
|
|
146
|
-
in a relation, subject types in `fromField`, relations, permissions and
|
|
147
|
-
arrows in a rule and in `when`; a wrong name's error lists the names it
|
|
148
|
-
could have been. — v0.1.2
|