@nxgt/janus 0.16.0 → 0.17.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 +99 -11
- package/dist/auth/config/index.d.ts +1 -1
- package/dist/auth/config/index.d.ts.map +1 -1
- package/dist/auth/config/janus-config.d.ts +24 -3
- package/dist/auth/config/janus-config.d.ts.map +1 -1
- package/dist/auth/config/resolve-config.d.ts.map +1 -1
- package/dist/auth/config/resolve-devices.d.ts +5 -0
- package/dist/auth/config/resolve-devices.d.ts.map +1 -0
- package/dist/auth/config/resolved-config.d.ts +4 -0
- package/dist/auth/config/resolved-config.d.ts.map +1 -1
- package/dist/auth/devices/hint.d.ts +30 -0
- package/dist/auth/devices/hint.d.ts.map +1 -0
- package/dist/auth/devices/index.d.ts +10 -0
- package/dist/auth/devices/index.d.ts.map +1 -0
- package/dist/auth/devices/report.d.ts +11 -0
- package/dist/auth/devices/report.d.ts.map +1 -0
- package/dist/auth/devices/token.d.ts +13 -0
- package/dist/auth/devices/token.d.ts.map +1 -0
- package/dist/auth/email-sign-in.d.ts +4 -2
- package/dist/auth/email-sign-in.d.ts.map +1 -1
- package/dist/auth/events.d.ts +18 -5
- package/dist/auth/events.d.ts.map +1 -1
- package/dist/auth/index.d.ts +2 -2
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/magic-link.d.ts.map +1 -1
- package/dist/auth/second-factor/challenge.d.ts +2 -2
- package/dist/auth/second-factor/challenge.d.ts.map +1 -1
- package/dist/auth/second-factor/flows.d.ts +6 -4
- package/dist/auth/second-factor/flows.d.ts.map +1 -1
- package/dist/auth/second-factor/recovery.d.ts +6 -2
- package/dist/auth/second-factor/recovery.d.ts.map +1 -1
- package/dist/auth/sessions/open-session.d.ts +7 -1
- package/dist/auth/sessions/open-session.d.ts.map +1 -1
- package/dist/auth/sign-in-code.d.ts.map +1 -1
- package/dist/auth/types/email-flows.d.ts +2 -2
- package/dist/auth/types/email-flows.d.ts.map +1 -1
- package/dist/auth/types/index.d.ts +1 -1
- package/dist/auth/types/index.d.ts.map +1 -1
- package/dist/auth/types/magic-link.d.ts +2 -2
- package/dist/auth/types/magic-link.d.ts.map +1 -1
- package/dist/auth/types/password.d.ts +10 -3
- package/dist/auth/types/password.d.ts.map +1 -1
- package/dist/auth/types/second-factor.d.ts +6 -3
- package/dist/auth/types/second-factor.d.ts.map +1 -1
- package/dist/auth/types/sign-in.d.ts +32 -0
- package/dist/auth/types/sign-in.d.ts.map +1 -1
- package/dist/auth/users/flow-types.d.ts +2 -1
- package/dist/auth/users/flow-types.d.ts.map +1 -1
- package/dist/auth/users/password-flows.d.ts.map +1 -1
- package/dist/auth/users/sign-in.d.ts +5 -1
- package/dist/auth/users/sign-in.d.ts.map +1 -1
- package/dist/index.js +172 -73
- package/dist/index.js.map +19 -15
- package/docs/README.md +2 -1
- package/docs/guide/devices.md +428 -0
- package/docs/guide/events.md +53 -8
- package/docs/guide/magic-link.md +25 -3
- package/docs/guide/second-factor.md +30 -5
- package/docs/guide/sessions.md +8 -0
- package/docs/guide/sign-in-code.md +22 -3
- package/docs/guide/step-up.md +33 -0
- package/docs/guide/users.md +3 -2
- package/docs/guide/vocabulary.md +4 -3
- package/docs/roadmap.md +12 -8
- package/docs/troubleshooting.md +210 -4
- package/package.json +1 -1
package/docs/README.md
CHANGED
|
@@ -18,7 +18,8 @@ below are defined once, in [Words](guide/vocabulary.md#words).
|
|
|
18
18
|
| [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 |
|
|
19
19
|
| [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, recovery codes for a lost phone, disabling |
|
|
20
20
|
| [Step-up](guide/step-up.md) | A sensitive action — changing the e-mail, disabling the second factor, deleting the account — should ask a signed-in user to prove again who they are: `assertFresh`, `stepUp.request` and `confirm`, by e-mailed code or by their app |
|
|
21
|
-
| [
|
|
21
|
+
| [Devices](guide/devices.md) | You want to tell a user their account was signed in from a new device: wiring `devices`, the `device` each sign-in takes, the device cookie, the second factor, the notice by e-mail, rotating the keys and forgetting devices |
|
|
22
|
+
| [User events](guide/events.md) | You want to hear when a user is created, verifies their e-mail, resets or changes their password, changes their e-mail, turns the second factor on or off, uses a recovery code, signs in from a new device or is deleted — to queue it, sync it, or send it as a webhook |
|
|
22
23
|
| [Password hashing](guide/passwords.md) | You are choosing a hasher, raising its cost, moving to argon2id, or importing hashes from another system — or how `signIn` throttles password guessing per login, and what you still limit per client |
|
|
23
24
|
|
|
24
25
|
### Permissions — `@nxgt/janus/permissions`
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
# Devices — a sign-in from a new device
|
|
2
|
+
|
|
3
|
+
This page is for telling a user when their account is signed in from a
|
|
4
|
+
device they had not signed in from: wiring `devices`, giving each sign-in the
|
|
5
|
+
**device token** the client holds, keeping it in a cookie, and sending the
|
|
6
|
+
notice.
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
import { z } from 'zod';
|
|
10
|
+
import { createMemoryStores, janus, scryptHasher } from '@nxgt/janus';
|
|
11
|
+
|
|
12
|
+
const auth = janus({
|
|
13
|
+
user: z.object({ email: z.email() }),
|
|
14
|
+
password: { login: 'email' },
|
|
15
|
+
store: createMemoryStores(),
|
|
16
|
+
hasher: scryptHasher(),
|
|
17
|
+
devices: { keys: [{ id: '2026-09', key: process.env.DEVICES_KEY ?? '' }] }, // openssl rand -base64 32
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const credentials = { email: 'ada@example.com', password: 'correct horse' };
|
|
21
|
+
|
|
22
|
+
const signedUp = await auth.signUp(credentials, { device: null }); // no token yet
|
|
23
|
+
signedUp.deviceToken; // 'd1.2026-09.…' — keep it in a long-lived cookie
|
|
24
|
+
signedUp.newDevice; // false: a sign-up is never new
|
|
25
|
+
|
|
26
|
+
const again = await auth.signIn(credentials, { device: signedUp.deviceToken });
|
|
27
|
+
again.newDevice; // false: the device Ada signed up on
|
|
28
|
+
|
|
29
|
+
const elsewhere = await auth.signIn(credentials, { device: null }); // a browser with no cookie
|
|
30
|
+
elsewhere.newDevice; // true — and the event user.newDeviceSignedIn was sent
|
|
31
|
+
elsewhere.deviceToken; // a fresh token, for that browser's cookie
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
**Nothing is stored.** A device token is a random device id and a keyed hash
|
|
35
|
+
binding it to the user: the client keeps it, and it proves "this user signed
|
|
36
|
+
in on this device before" on its own. The store ports are unchanged, and no
|
|
37
|
+
adapter needs a migration. The words — **device**, **device token** — are
|
|
38
|
+
defined in [the vocabulary](vocabulary.md#identities).
|
|
39
|
+
|
|
40
|
+
## Wiring
|
|
41
|
+
|
|
42
|
+
| Option | Type | Default | Effect |
|
|
43
|
+
| --- | --- | --- | --- |
|
|
44
|
+
| `devices.keys` | `[SealingKey, ...SealingKey[]]` | — required once `devices` is given | The keys device tokens are signed with. **The first signs, every one checks.** See [Rotating the keys](#rotating-the-keys-and-forgetting-devices) |
|
|
45
|
+
|
|
46
|
+
Without `devices`, no sign-in may be given a `device`: every answer's
|
|
47
|
+
`newDevice` is `false`, its `deviceToken` `null`, and no
|
|
48
|
+
`user.newDeviceSignedIn` is sent.
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
interface DevicesConfig {
|
|
52
|
+
readonly keys: readonly [SealingKey, ...SealingKey[]];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface SealingKey {
|
|
56
|
+
readonly id: string; // letters, digits, _ and -, at most 64: written into every token it signs
|
|
57
|
+
readonly key: string; // 32 random bytes, in base64 or base64url
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The keys have the format of [`secondFactor.keys`](second-factor.md#making-a-key),
|
|
62
|
+
and are made the same way:
|
|
63
|
+
|
|
64
|
+
```sh
|
|
65
|
+
openssl rand -base64 32
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Use a key of its own rather than a sealing key of the second factor's: the
|
|
69
|
+
two rotate for different reasons. Keep it where your other secrets are, and
|
|
70
|
+
give **every `janus()` that signs users in the same `devices`** — a token
|
|
71
|
+
signed by an instance whose keys another does not hold is a new device there.
|
|
72
|
+
|
|
73
|
+
`janus()` refuses a configuration it cannot sign with, with a bare
|
|
74
|
+
`TypeError`:
|
|
75
|
+
|
|
76
|
+
| Message | Cause |
|
|
77
|
+
| --- | --- |
|
|
78
|
+
| `janus: devices.keys: expected at least one key — [{ id, key }], the first seals` | `keys` missing or empty |
|
|
79
|
+
| `janus: devices.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 |
|
|
80
|
+
| `janus: devices.keys: two keys have the id "<id>"` | a repeated `id` |
|
|
81
|
+
| `janus: devices.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 — an environment variable that is not set, say |
|
|
82
|
+
|
|
83
|
+
`devices: { keys: [] }` is also a compile error.
|
|
84
|
+
|
|
85
|
+
## Giving a sign-in the device
|
|
86
|
+
|
|
87
|
+
Every call that opens a session takes `{ device }` as its last argument, a
|
|
88
|
+
`SignInOptions`:
|
|
89
|
+
|
|
90
|
+
| Call | Given a device |
|
|
91
|
+
| --- | --- |
|
|
92
|
+
| `signUp(input, { device })` | mints the user's first token: `newDevice` is always `false`, and no event is sent — the device they signed up on is known from the start |
|
|
93
|
+
| `signIn(input, { device })` | answers `newDevice` and `deviceToken`; a new device sends `user.newDeviceSignedIn`. With an active second factor, the challenge — see [below](#with-a-second-factor) |
|
|
94
|
+
| `secondFactor.confirm(challenge, code, { device })` | the same, once the code is right |
|
|
95
|
+
| `secondFactor.recover(challenge, code, { device })` | the same, after `user.recoveryCodeUsed` |
|
|
96
|
+
| `signInCode.confirm(challenge, code, { device })` | the same — or a challenge, with an active second factor |
|
|
97
|
+
| `magicLink.confirm(token, { device })` | the same — or a challenge, with an active second factor |
|
|
98
|
+
|
|
99
|
+
A step-up opens no session, and takes no device. With several user types,
|
|
100
|
+
the calls are the type's: `clinic.staff.signIn(input, { device })`.
|
|
101
|
+
|
|
102
|
+
What `device` says:
|
|
103
|
+
|
|
104
|
+
| `device` | Means | The answer |
|
|
105
|
+
| --- | --- | --- |
|
|
106
|
+
| a string | the token the client holds — an earlier answer's `deviceToken` | `newDevice: false` and the same token when it proves this user signed in there; otherwise `newDevice: true` and a fresh token |
|
|
107
|
+
| `null` | the client holds no token yet | `newDevice: true` and a fresh token — `false` on `signUp` |
|
|
108
|
+
| absent | **devices are not tracked for this call** | `newDevice: false`, `deviceToken: null`, no event |
|
|
109
|
+
|
|
110
|
+
**A token that proves nothing is a new device, never an error.** Malformed,
|
|
111
|
+
forged, another user's, or signed with a key no longer held: the sign-in
|
|
112
|
+
succeeds, answers `newDevice: true` and a fresh token. The value came from a
|
|
113
|
+
request, so nothing about it is refused — only its type, from JavaScript.
|
|
114
|
+
|
|
115
|
+
**`undefined` is not `null`.** A cookie read as `string | undefined` and
|
|
116
|
+
handed over as it is makes every browser without a cookie untracked: it is
|
|
117
|
+
never given a token, and never reported. With `exactOptionalPropertyTypes`
|
|
118
|
+
in your tsconfig that is a compile error — `TS2379: … Type 'string |
|
|
119
|
+
undefined' is not assignable to type 'string | null'` — and without it, it
|
|
120
|
+
compiles and tracks nothing. Say what you mean:
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
const cookie: string | undefined = readCookie(request, 'device'); // yours
|
|
124
|
+
await auth.signIn(credentials, { device: cookie ?? null });
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## What the answer says
|
|
128
|
+
|
|
129
|
+
`SignedIn` — and so `RecoveredSignIn` — carries two more fields:
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
interface SignedIn<U> {
|
|
133
|
+
readonly status: 'signedIn';
|
|
134
|
+
readonly user: U;
|
|
135
|
+
readonly session: Session;
|
|
136
|
+
readonly token: string;
|
|
137
|
+
readonly newDevice: boolean; // false without a device, and on signUp
|
|
138
|
+
readonly deviceToken: string | null; // null without a device: nothing minted
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
- **`deviceToken`** is what the client keeps, and presents as `device` at its
|
|
143
|
+
next sign-in: the token it presented, a fresh one for a new device, or the
|
|
144
|
+
same device signed again with the first key after a
|
|
145
|
+
[rotation](#rotating-the-keys-and-forgetting-devices). Set the cookie again
|
|
146
|
+
on every sign-in: it keeps it alive.
|
|
147
|
+
- **`newDevice`** is `true` when the session was opened from a device the
|
|
148
|
+
user had not signed in from — and `user.newDeviceSignedIn` was then sent.
|
|
149
|
+
|
|
150
|
+
A test double that builds a `SignedIn` by hand needs both fields:
|
|
151
|
+
`{ …, newDevice: false, deviceToken: null }`.
|
|
152
|
+
|
|
153
|
+
## With a second factor
|
|
154
|
+
|
|
155
|
+
A user whose second factor is active gets a challenge from `signIn`, and
|
|
156
|
+
**the challenge carries no device**. Give the device again to
|
|
157
|
+
`secondFactor.confirm` or `secondFactor.recover`, from the same cookie:
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
const result = await auth.signIn(credentials, { device: cookie ?? null });
|
|
161
|
+
if (result.status === 'secondFactor') {
|
|
162
|
+
// …the next request, with the code:
|
|
163
|
+
const signedIn = await auth.secondFactor.confirm(result.challenge, code, { device: cookie ?? null });
|
|
164
|
+
signedIn.newDevice; // decided here, once the code is right
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
The challenge answer has no `newDevice` and no `deviceToken`, and sends no
|
|
169
|
+
event: nothing is known until the code is. A confirmation given no device is
|
|
170
|
+
untracked, whatever `signIn` was given. The same holds for a
|
|
171
|
+
`signInCode.confirm` or `magicLink.confirm` that answered a challenge.
|
|
172
|
+
|
|
173
|
+
## Keeping the token: the cookie
|
|
174
|
+
|
|
175
|
+
The token is made of cookie-value characters — `d1.<key id>.<device id>.<mac>`
|
|
176
|
+
— so it goes into a cookie as it is. Keep it in **its own long-lived cookie**,
|
|
177
|
+
`HttpOnly` and `Secure`, apart from the session cookie: it outlives every
|
|
178
|
+
session, and signing out leaves it, so the device stays known.
|
|
179
|
+
|
|
180
|
+
With Hono, [`@nxgt/janus-hono`](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus-hono/README.md)
|
|
181
|
+
reads it with `deviceOf(c)` and sets it in `sendSession` whenever the answer
|
|
182
|
+
carries a `deviceToken`:
|
|
183
|
+
|
|
184
|
+
```ts
|
|
185
|
+
import { deviceOf, sendSession } from '@nxgt/janus-hono';
|
|
186
|
+
|
|
187
|
+
app.post('/sign-in', async (c) => {
|
|
188
|
+
const { email, password } = await c.req.json();
|
|
189
|
+
const signedIn = await auth.signIn({ email, password }, { device: deviceOf(c) });
|
|
190
|
+
const user = sendSession(c, auth, signedIn); // the session cookie, and the device cookie
|
|
191
|
+
return c.json({ id: user.id });
|
|
192
|
+
});
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Anywhere else, a fetch-style handler sets both cookies itself:
|
|
196
|
+
|
|
197
|
+
```ts
|
|
198
|
+
const DEVICE = 'device';
|
|
199
|
+
const FOUR_HUNDRED_DAYS = 400 * 24 * 60 * 60; // the longest a browser keeps a cookie
|
|
200
|
+
|
|
201
|
+
function cookieOf(request: Request, name: string): string | null {
|
|
202
|
+
const match = request.headers.get('cookie')?.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`));
|
|
203
|
+
return match?.[1] || null; // no cookie, or an empty one: the client holds no token
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export async function signIn(request: Request): Promise<Response> {
|
|
207
|
+
const { email, password } = (await request.json()) as { email: string; password: string };
|
|
208
|
+
const signedIn = await auth.signIn({ email, password }, { device: cookieOf(request, DEVICE) });
|
|
209
|
+
const headers = new Headers();
|
|
210
|
+
headers.append('Set-Cookie', auth.cookie.serialize(signedIn.token, signedIn.session));
|
|
211
|
+
if (signedIn.deviceToken !== null) {
|
|
212
|
+
headers.append(
|
|
213
|
+
'Set-Cookie',
|
|
214
|
+
`${DEVICE}=${signedIn.deviceToken}; Max-Age=${FOUR_HUNDRED_DAYS}; Path=/; HttpOnly; Secure; SameSite=Lax`,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
return Response.json({ id: signedIn.user.id }, { headers });
|
|
218
|
+
}
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
A bearer client — a mobile app — keeps `deviceToken` beside its session
|
|
222
|
+
token, and sends it with its next sign-in.
|
|
223
|
+
|
|
224
|
+
**One cookie holds one user's token.** Two people who share a browser and
|
|
225
|
+
take turns signing in each present the other's token — which proves nothing
|
|
226
|
+
for them — and each gets a new token and a notice at every turn. That is
|
|
227
|
+
the price of storing nothing.
|
|
228
|
+
|
|
229
|
+
## The event: `user.newDeviceSignedIn`
|
|
230
|
+
|
|
231
|
+
A sign-in from a new device sends
|
|
232
|
+
[`user.newDeviceSignedIn`](events.md#the-eleven-types) to `janus({ events })`:
|
|
233
|
+
|
|
234
|
+
```ts
|
|
235
|
+
{
|
|
236
|
+
id: '0199…', // a UUIDv7 minted for this event
|
|
237
|
+
type: 'user.newDeviceSignedIn',
|
|
238
|
+
occurredAt: Date, // the new session's createdAt
|
|
239
|
+
userId: '0199…',
|
|
240
|
+
userType: 'user',
|
|
241
|
+
sessionId: '0199…', // the session the new device holds
|
|
242
|
+
}
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
It is the second exception to *the user by id only*, after `formerEmail`:
|
|
246
|
+
`sessionId` names the session the new device holds, so a "this wasn't me"
|
|
247
|
+
page can end that one — `store.sessions.revokeSession(sessionId, new Date())`
|
|
248
|
+
on the store you wired — or every session with
|
|
249
|
+
`auth.signOutEverywhere(user)`. `@nxgt/janus-webhooks` posts the event
|
|
250
|
+
without it: a session id stays in the process.
|
|
251
|
+
|
|
252
|
+
- **Sent once the sign-in is complete.** A wrong password sends nothing, nor
|
|
253
|
+
does a `signIn` refused after its session opened — a password written
|
|
254
|
+
meanwhile — nor a challenge.
|
|
255
|
+
- **After the others.** `secondFactor.recover` sends it after
|
|
256
|
+
`user.recoveryCodeUsed`; a first proof of the e-mail by
|
|
257
|
+
[code](sign-in-code.md) or [link](magic-link.md) sends it last, after
|
|
258
|
+
`user.emailVerified` and what that proof removed.
|
|
259
|
+
- **Never** from `signUp`, from a call given no device, or from a device the
|
|
260
|
+
token proved.
|
|
261
|
+
|
|
262
|
+
## The e-mail: `@nxgt/janus-mail`'s `newSignIn`
|
|
263
|
+
|
|
264
|
+
[`@nxgt/janus-mail`](https://www.npmjs.com/package/@nxgt/janus-mail) sends
|
|
265
|
+
the notice, in English and French — "New sign-in to your account", the
|
|
266
|
+
device, where, when, and a button to your security settings. Every value is
|
|
267
|
+
your text, in the recipient's locale: **Janus sees no IP and no
|
|
268
|
+
`User-Agent`**, so describing the device, formatting the time in the user's
|
|
269
|
+
time zone and finding a location — from a geo-IP of your own, if you have one
|
|
270
|
+
— are yours. Without a location, the e-mail shows `—`.
|
|
271
|
+
|
|
272
|
+
From the sign-in's answer, where the request is in hand:
|
|
273
|
+
|
|
274
|
+
```ts
|
|
275
|
+
// `mail` is janusMail({ … }), and the user schema holds a name and a locale.
|
|
276
|
+
const signedIn = await auth.signIn({ email, password }, { device: cookieOf(request, DEVICE) });
|
|
277
|
+
if (signedIn.newDevice) {
|
|
278
|
+
const { user } = signedIn;
|
|
279
|
+
await mail.newSignIn(
|
|
280
|
+
{ name: user.name, email: user.email, locale: user.locale },
|
|
281
|
+
{
|
|
282
|
+
device: describeDevice(request.headers.get('user-agent')), // yours: 'Firefox on macOS'
|
|
283
|
+
time: new Intl.DateTimeFormat(user.locale, { dateStyle: 'long', timeStyle: 'short', timeZone }).format(
|
|
284
|
+
signedIn.session.createdAt,
|
|
285
|
+
),
|
|
286
|
+
},
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
Or from the event, which names the user by id alone:
|
|
292
|
+
|
|
293
|
+
```ts
|
|
294
|
+
const auth = janus({
|
|
295
|
+
...config,
|
|
296
|
+
async events(event) {
|
|
297
|
+
if (event.type !== 'user.newDeviceSignedIn') return;
|
|
298
|
+
const user = await auth.get(event.userId);
|
|
299
|
+
const time = new Intl.DateTimeFormat(user.locale, { dateStyle: 'long', timeStyle: 'short', timeZone }).format(
|
|
300
|
+
event.occurredAt,
|
|
301
|
+
);
|
|
302
|
+
await mail.newSignIn({ name: user.name, email: user.email, locale: user.locale }, { device: 'a new device', time });
|
|
303
|
+
},
|
|
304
|
+
});
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
The event has no request to describe the device from: send from the answer
|
|
308
|
+
when you want the device named, or put the description in the job your
|
|
309
|
+
route queues.
|
|
310
|
+
|
|
311
|
+
## What a token proves, and what it does not
|
|
312
|
+
|
|
313
|
+
A device token is `d1.<key id>.<device id>.<mac>`: 16 random bytes of device
|
|
314
|
+
id, and an HMAC-SHA256 of the user's id and the device id, under a key
|
|
315
|
+
derived (HKDF) from the device key. So:
|
|
316
|
+
|
|
317
|
+
- **It is bound to the user.** A token copied onto another user's sign-in
|
|
318
|
+
proves nothing there, and is a new device.
|
|
319
|
+
- **It cannot be forged** without the key, and a changed character proves
|
|
320
|
+
nothing.
|
|
321
|
+
- **It proves only "this user signed in here before".** It is not a
|
|
322
|
+
session and not a credential: never authenticate a request with it, never
|
|
323
|
+
let it skip a password or a second factor. Whoever copies the cookie
|
|
324
|
+
copies a device, and the worst it buys is one notice fewer.
|
|
325
|
+
|
|
326
|
+
## Rotating the keys, and forgetting devices
|
|
327
|
+
|
|
328
|
+
The first key signs; every key checks. To rotate, put a new key first and
|
|
329
|
+
keep the old one:
|
|
330
|
+
|
|
331
|
+
```ts
|
|
332
|
+
devices: {
|
|
333
|
+
keys: [
|
|
334
|
+
{ id: '2027-03', key: process.env.DEVICES_KEY_2027_03 ?? '' }, // signs
|
|
335
|
+
{ id: '2026-09', key: process.env.DEVICES_KEY_2026_09 ?? '' }, // still checks
|
|
336
|
+
],
|
|
337
|
+
},
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
A token signed by the older key is known, and the answer's `deviceToken` is
|
|
341
|
+
the same device signed again with the first: every device that signs in
|
|
342
|
+
moves to the new key by itself.
|
|
343
|
+
|
|
344
|
+
**Removing a key is the only way to forget devices, and it forgets every
|
|
345
|
+
device that key signed.** Each one is reported new once, at its next
|
|
346
|
+
sign-in, then known again. There is no way to forget one device alone —
|
|
347
|
+
nothing is stored to delete. After a breach, drop every key and start with a
|
|
348
|
+
new one: every user gets one notice per device.
|
|
349
|
+
|
|
350
|
+
## Errors
|
|
351
|
+
|
|
352
|
+
All three are bare `TypeError`s — wiring mistakes, thrown before anything is
|
|
353
|
+
written. `<call>` is the call you wrote: `signIn`, `secondFactor.confirm`, or
|
|
354
|
+
`patient.signIn` with several user types.
|
|
355
|
+
|
|
356
|
+
| Message | Cause |
|
|
357
|
+
| --- | --- |
|
|
358
|
+
| `<call>: a device was given, but janus() has no devices — pass devices: { keys }` | `{ device }`, even `null`, given to a `janus()` wired without `devices` |
|
|
359
|
+
| `<call>: options must be { device }, or left out — the token goes under device` | options that are not an object — the token itself where `{ device }` belongs — from JavaScript or through a cast |
|
|
360
|
+
| `<call>: options.device must be the device token the client holds, or null when it holds none` | a `device` that is neither a string nor `null`, from JavaScript or through a cast |
|
|
361
|
+
|
|
362
|
+
The configuration's are [above](#wiring). Every call may still reject as it
|
|
363
|
+
did — `CREDENTIALS_INVALID`, `CODE_INVALID`, `STORE_FAILED` — and a device
|
|
364
|
+
never adds a refusal of its own.
|
|
365
|
+
|
|
366
|
+
## In a test
|
|
367
|
+
|
|
368
|
+
```ts
|
|
369
|
+
import { expect, it } from 'bun:test';
|
|
370
|
+
import { z } from 'zod';
|
|
371
|
+
import { createMemoryStores, janus, scryptHasher, type UserEvent } from '@nxgt/janus';
|
|
372
|
+
|
|
373
|
+
it('tells a new device from a known one', async () => {
|
|
374
|
+
const received: UserEvent[] = [];
|
|
375
|
+
const auth = janus({
|
|
376
|
+
user: z.object({ email: z.email() }),
|
|
377
|
+
password: { login: 'email' },
|
|
378
|
+
store: createMemoryStores(),
|
|
379
|
+
hasher: scryptHasher({ cost: 10 }), // fast in tests; keep the default in production
|
|
380
|
+
devices: { keys: [{ id: 'test', key: Buffer.alloc(32, 7).toString('base64') }] },
|
|
381
|
+
events: (event) => void received.push(event),
|
|
382
|
+
});
|
|
383
|
+
const credentials = { email: 'ada@example.com', password: 'correct horse' };
|
|
384
|
+
const { deviceToken } = await auth.signUp(credentials, { device: null });
|
|
385
|
+
|
|
386
|
+
const known = await auth.signIn(credentials, { device: deviceToken });
|
|
387
|
+
const fresh = await auth.signIn(credentials, { device: null });
|
|
388
|
+
|
|
389
|
+
expect(known.newDevice).toBe(false);
|
|
390
|
+
expect(fresh.newDevice).toBe(true);
|
|
391
|
+
expect(received.filter((event) => event.type === 'user.newDeviceSignedIn')).toEqual([
|
|
392
|
+
expect.objectContaining({ userId: fresh.user.id, sessionId: fresh.session.id }),
|
|
393
|
+
]);
|
|
394
|
+
});
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
## Signatures
|
|
398
|
+
|
|
399
|
+
```ts
|
|
400
|
+
interface SignInOptions {
|
|
401
|
+
readonly device?: string | null; // the token the client holds, or null for none; absent: untracked
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// On every user type with a password:
|
|
405
|
+
signUp(input: In & { readonly password: string }, options?: SignInOptions): Promise<SignedIn<U>>;
|
|
406
|
+
signIn(input: { readonly [K in Login]: string } & { readonly password: string }, options?: SignInOptions): Promise<Answer>;
|
|
407
|
+
secondFactor.confirm(challenge: string, code: string, options?: SignInOptions): Promise<SignedIn<U>>;
|
|
408
|
+
secondFactor.recover(challenge: string, code: string, options?: SignInOptions): Promise<RecoveredSignIn<U>>;
|
|
409
|
+
|
|
410
|
+
// On every user type with an e-mail:
|
|
411
|
+
signInCode.confirm(challenge: string, code: string, options?: SignInOptions): Promise<Answer>;
|
|
412
|
+
magicLink.confirm(token: string, options?: SignInOptions): Promise<Answer>;
|
|
413
|
+
|
|
414
|
+
janus({ ..., devices?: DevicesConfig });
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
`Answer` is `SignInResult<U>` on a type with a password in an instance given
|
|
418
|
+
a `secondFactor`, and `SignedIn<U>` everywhere else. `DevicesConfig`,
|
|
419
|
+
`SignInOptions` and `SealingKey` are exported types of `@nxgt/janus`.
|
|
420
|
+
|
|
421
|
+
## See also
|
|
422
|
+
|
|
423
|
+
- [User events](events.md) — `user.newDeviceSignedIn` among the eleven types, and when the listener runs
|
|
424
|
+
- [The second factor](second-factor.md#confirming-the-code-at-sign-in) — the challenge the device is given again after
|
|
425
|
+
- [Sessions](sessions.md) — the session cookie, beside the device cookie
|
|
426
|
+
- [`@nxgt/janus-hono`](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus-hono/docs/guide/routes.md#the-device-cookie) — `deviceOf` and `sendSession`'s device cookie
|
|
427
|
+
- [`@nxgt/janus-mail`](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus-mail/docs/guide/sending.md#newsigninto--device-time-location-) — `newSignIn`
|
|
428
|
+
- [Troubleshooting](../troubleshooting.md#devices) — every sign-in reports a new device, and the messages above
|
package/docs/guide/events.md
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
This page is for hearing what happens to a user once it is written: created,
|
|
4
4
|
e-mail verified, password reset or changed, e-mail changed, second factor
|
|
5
|
-
turned on or off, recovery codes regenerated or one used,
|
|
5
|
+
turned on or off, recovery codes regenerated or one used, a sign-in from a
|
|
6
|
+
new device, deleted. Another service can then follow
|
|
6
7
|
without polling. `janus` hands each event to one function you give it;
|
|
7
8
|
**delivering it is yours** — or
|
|
8
9
|
[`@nxgt/janus-webhooks`](https://www.npmjs.com/package/@nxgt/janus-webhooks)'s:
|
|
@@ -38,7 +39,7 @@ received[0];
|
|
|
38
39
|
The words — **user event**, **listener** — are defined in
|
|
39
40
|
[the vocabulary](vocabulary.md#identities).
|
|
40
41
|
|
|
41
|
-
## The
|
|
42
|
+
## The eleven types
|
|
42
43
|
|
|
43
44
|
| `type` | Sent by | Not sent |
|
|
44
45
|
| --- | --- | --- |
|
|
@@ -51,16 +52,18 @@ The words — **user event**, **listener** — are defined in
|
|
|
51
52
|
| `user.secondFactorDisabled` | `secondFactor.disable`, when it removed an **active** factor; `signInCode.confirm` and `magicLink.confirm` when their first proof of the e-mail removed one, after `user.emailVerified` and any `user.passwordChanged` — even if an outage interrupts the sign-outs | for a user who had no factor, a factor still waiting for its first code — it was never asked for — or a second `disable` |
|
|
52
53
|
| `user.recoveryCodesRegenerated` | `secondFactor.regenerateRecoveryCodes`: the codes the user held stopped working | for `activate`, whose first codes come with `user.secondFactorEnabled`; for a code refused |
|
|
53
54
|
| `user.recoveryCodeUsed` | `secondFactor.recover`, once the recovery code is spent — even if opening the session then fails | for a recovery code refused. How many are left is not in the event: `secondFactor.recoveryCodesLeft(event.userId)` reads it |
|
|
55
|
+
| `user.newDeviceSignedIn` | `signIn`, `secondFactor.confirm`, `secondFactor.recover`, `signInCode.confirm` and `magicLink.confirm`, given a `device` whose token did not prove this user signed in there — once the sign-in is complete. It carries `sessionId`, the new session's. See [devices](devices.md) | for `signUp`, which mints the first token; for a call given no `device`; for a known device; for a challenge, which opens no session; for a sign-in refused — a wrong password, or a `signIn` refused after its session opened because a password was written meanwhile |
|
|
54
56
|
| `user.deleted` | `delete`, when it deleted the user | for a replay that finds nobody, or an id of another user type |
|
|
55
57
|
|
|
56
58
|
A reset whose link verifies the e-mail sends both, `user.passwordReset`
|
|
57
59
|
first. **A reset does not also send `user.passwordChanged`**: a listener that
|
|
58
60
|
cares about every new password handles both types, as the `switch` below
|
|
59
61
|
does — one event per write, rather than two a listener would have to tell
|
|
60
|
-
apart. Switch on `type`: the
|
|
61
|
-
|
|
62
|
-
two recovery-code types, added in 0.10,
|
|
63
|
-
0.14, each broke such a `switch`
|
|
62
|
+
apart. Switch on `type`: the eleven are a closed set, and TypeScript refuses a
|
|
63
|
+
twelfth. A new type is a compile error in a `switch` that exhausts them — the
|
|
64
|
+
two recovery-code types, added in 0.10, the two change types, added in
|
|
65
|
+
0.14, and `user.newDeviceSignedIn`, added in 0.17, each broke such a `switch`
|
|
66
|
+
until it handled them.
|
|
64
67
|
|
|
65
68
|
```ts
|
|
66
69
|
function onUserEvent(event: UserEvent): void {
|
|
@@ -81,6 +84,8 @@ function onUserEvent(event: UserEvent): void {
|
|
|
81
84
|
return;
|
|
82
85
|
case 'user.recoveryCodeUsed':
|
|
83
86
|
return warnTheUser(event.userId); // a sign-in without their phone — see below
|
|
87
|
+
case 'user.newDeviceSignedIn':
|
|
88
|
+
return warnTheUser(event.userId); // @nxgt/janus-mail's newSignIn — see below
|
|
84
89
|
case 'user.deleted':
|
|
85
90
|
return forgetEverywhere(event.userId);
|
|
86
91
|
}
|
|
@@ -123,6 +128,37 @@ With several user types, the count is the type's:
|
|
|
123
128
|
`auth.patient.secondFactor.recoveryCodesLeft(event.userId)`, on
|
|
124
129
|
`event.userType`.
|
|
125
130
|
|
|
131
|
+
### Telling the user a new device signed in
|
|
132
|
+
|
|
133
|
+
A sign-in from a device the user had not signed in from is how a stolen
|
|
134
|
+
password shows, so tell the user.
|
|
135
|
+
[`@nxgt/janus-mail`](https://www.npmjs.com/package/@nxgt/janus-mail)'s
|
|
136
|
+
`newSignIn` is the e-mail — the device, the time and, when you know it, the
|
|
137
|
+
place, each your text in the user's locale:
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
// `mail` is janusMail({ … }), and the user schema holds a name and a locale.
|
|
141
|
+
const auth = janus({
|
|
142
|
+
...config,
|
|
143
|
+
async events(event) {
|
|
144
|
+
if (event.type === 'user.newDeviceSignedIn') {
|
|
145
|
+
const user = await auth.get(event.userId);
|
|
146
|
+
const time = new Intl.DateTimeFormat(user.locale, {
|
|
147
|
+
dateStyle: 'long',
|
|
148
|
+
timeStyle: 'short',
|
|
149
|
+
timeZone: 'Europe/Paris', // the user's, when you keep it
|
|
150
|
+
}).format(event.occurredAt); // the new session's createdAt
|
|
151
|
+
await mail.newSignIn({ name: user.name, locale: user.locale, email: user.email }, { device: 'a new device', time });
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
The event has no request, so it cannot say which device: send from the
|
|
158
|
+
sign-in's answer instead — `if (signedIn.newDevice) …` — to name it from
|
|
159
|
+
the `User-Agent`. [Devices](devices.md#the-e-mail-nxgtjanus-mails-newsignin)
|
|
160
|
+
has both.
|
|
161
|
+
|
|
126
162
|
### Telling the user their password or e-mail changed
|
|
127
163
|
|
|
128
164
|
A password changed by someone else, or an e-mail moved to their inbox, is how
|
|
@@ -161,14 +197,14 @@ too, if a reset should be told as well.
|
|
|
161
197
|
|
|
162
198
|
## What an event carries
|
|
163
199
|
|
|
164
|
-
**The user named by id, and nothing else** — with
|
|
200
|
+
**The user named by id, and nothing else** — with two exceptions, below. No
|
|
165
201
|
login, no field of the schema, no password or hash, no session or one-time
|
|
166
202
|
token. An event can
|
|
167
203
|
land in a queue, a log or another company's endpoint; whoever receives it
|
|
168
204
|
reads the rest from where it is kept — `auth.get(event.userId)` — if they
|
|
169
205
|
may. A user deleted since is `NOT_FOUND` there, which is the answer.
|
|
170
206
|
|
|
171
|
-
**The exception is `formerEmail`, on `user.emailChanged` only**: the address
|
|
207
|
+
**The first exception is `formerEmail`, on `user.emailChanged` only**: the address
|
|
172
208
|
the user had before the update, as it was stored, or `null` when they had
|
|
173
209
|
none. Once the write landed it is kept nowhere else, and a notice to the
|
|
174
210
|
inbox the account just left needs it. It is absent from every other type.
|
|
@@ -176,6 +212,11 @@ inbox the account just left needs it. It is absent from every other type.
|
|
|
176
212
|
it reaches no endpoint, no queue and no `onGivingUp` report — send the
|
|
177
213
|
notice from the listener, in the process that wrote.
|
|
178
214
|
|
|
215
|
+
**The second is `sessionId`, on `user.newDeviceSignedIn` only**: the id of
|
|
216
|
+
the session the new device opened, so a "this wasn't me" page can end that
|
|
217
|
+
one. It is absent from every other type, and `@nxgt/janus-webhooks` strips
|
|
218
|
+
it as it strips `formerEmail`.
|
|
219
|
+
|
|
179
220
|
`id` is minted for the event, a UUIDv7 that sorts by time. Deliver on it:
|
|
180
221
|
a queue job id, a primary key, a webhook's `webhook-id` header. Two events
|
|
181
222
|
never share one.
|
|
@@ -207,6 +248,7 @@ Where the listener runs within a flow, and what an outage does to it:
|
|
|
207
248
|
| `signInCode.confirm`, `magicLink.confirm` | on an e-mail never verified: **after** every session is revoked and the reset links and second-factor challenges left open are spent — before the write and again after it — and **before** the new session or second-factor challenge is opened. `user.passwordChanged` follows `user.emailVerified` when a password was dropped, then `user.secondFactorDisabled` when an active second factor was removed. An e-mail already verified sends nothing | fails the call; the events are sent all the same, from a `finally`. An outage in the sign-outs before the write fails the call with nothing written and nothing sent: the next code or link proves the e-mail again |
|
|
208
249
|
| `secondFactor.activate`, `secondFactor.regenerateRecoveryCodes`, `secondFactor.disable` | after the write — the flow's last step | — |
|
|
209
250
|
| `secondFactor.recover` | after the recovery code is spent and the session opened — or failed to open | fails the call; the recovery code is spent and the event sent all the same, from a `finally` |
|
|
251
|
+
| `signIn`, `secondFactor.confirm`, `secondFactor.recover`, `signInCode.confirm`, `magicLink.confirm` — `user.newDeviceSignedIn` | **last**, once the session is open and the sign-in complete: after `user.recoveryCodeUsed`, and after the events of a first proof of the e-mail | fails the call, and `user.newDeviceSignedIn` is not sent: it comes only once the sign-in is complete |
|
|
210
252
|
| `resetPassword.confirm` | **after** the sessions opened with the old password are revoked, and the other reset links and the second-factor challenges left open are spent | fails the call; the events are sent all the same, from a `finally` |
|
|
211
253
|
| `delete` | **after** the user's sessions and one-time tokens are removed, and the relation tuples naming them when `relations` is wired | fails the call; the event is sent all the same, from a `finally` |
|
|
212
254
|
|
|
@@ -294,6 +336,7 @@ type UserEventType =
|
|
|
294
336
|
| 'user.secondFactorDisabled'
|
|
295
337
|
| 'user.recoveryCodesRegenerated'
|
|
296
338
|
| 'user.recoveryCodeUsed'
|
|
339
|
+
| 'user.newDeviceSignedIn'
|
|
297
340
|
| 'user.deleted';
|
|
298
341
|
|
|
299
342
|
interface UserEvent {
|
|
@@ -303,6 +346,7 @@ interface UserEvent {
|
|
|
303
346
|
readonly userId: Id;
|
|
304
347
|
readonly userType: string;
|
|
305
348
|
readonly formerEmail?: string | null; // on user.emailChanged only: the address before, null for none
|
|
349
|
+
readonly sessionId?: string; // on user.newDeviceSignedIn only: the session the new device opened
|
|
306
350
|
}
|
|
307
351
|
|
|
308
352
|
type UserEventListener = (event: UserEvent) => void | Promise<void>;
|
|
@@ -317,6 +361,7 @@ see [troubleshooting](../troubleshooting.md#janus-events-must-be-a-function-that
|
|
|
317
361
|
|
|
318
362
|
- [E-mail verification and password reset](email-flows.md) — the flows that send `user.emailVerified` and `user.passwordReset`
|
|
319
363
|
- [The second factor](second-factor.md) — `activate` and `disable`, which send `user.secondFactorEnabled` and `user.secondFactorDisabled` — sent too by the first proof of an e-mail by a [code](sign-in-code.md) or a [link](magic-link.md#an-account-someone-else-registered); [recovery codes](second-factor.md#recovery-codes), which send `user.recoveryCodesRegenerated` and `user.recoveryCodeUsed`
|
|
364
|
+
- [Devices](devices.md) — the sign-ins that send `user.newDeviceSignedIn`, the device token and the cookie
|
|
320
365
|
- [Users](users.md) — `create`, `signUp`, `update`, `delete`
|
|
321
366
|
- [Passwords](passwords.md) — `changePassword` and `setPassword`, which send `user.passwordChanged`
|
|
322
367
|
- [Troubleshooting](../troubleshooting.md) — `JANUS_EVENT_FAILED`
|
package/docs/guide/magic-link.md
CHANGED
|
@@ -286,6 +286,25 @@ a password, in an instance given a `secondFactor` — and reading `token`
|
|
|
286
286
|
before narrowing on `status` is a compile error there. Anywhere else it
|
|
287
287
|
answers `SignedIn`.
|
|
288
288
|
|
|
289
|
+
### The device
|
|
290
|
+
|
|
291
|
+
With [`janus({ devices })`](devices.md), `confirm` takes the device token
|
|
292
|
+
the client holds as its second argument — read from the device cookie of
|
|
293
|
+
the browser that posts the link's page, which is the one the session opens
|
|
294
|
+
in:
|
|
295
|
+
|
|
296
|
+
```ts
|
|
297
|
+
const signedIn = await auth.magicLink.confirm(token, { device: cookie ?? null });
|
|
298
|
+
// { status: 'signedIn', …, newDevice, deviceToken }: keep deviceToken in the device cookie
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
A new device sends `user.newDeviceSignedIn` — last, after the events a first
|
|
302
|
+
proof of the e-mail sends. A link opened on a phone the user never signed in
|
|
303
|
+
from is a new device, even when a laptop asked for it. When `confirm`
|
|
304
|
+
answers a second factor's challenge, the challenge carries no device: give
|
|
305
|
+
it again to `secondFactor.confirm`. Without `{ device }`, nothing is
|
|
306
|
+
tracked.
|
|
307
|
+
|
|
289
308
|
### What `confirm` refuses
|
|
290
309
|
|
|
291
310
|
| Rejects with | When | What to do |
|
|
@@ -414,14 +433,16 @@ it('signs in with the e-mailed link, once, and not after fifteen minutes', async
|
|
|
414
433
|
interface MagicLinkApi<U, Answer = SignedIn<U>> {
|
|
415
434
|
readonly magicLink: {
|
|
416
435
|
request(email: string): Promise<(IssuedToken & { readonly user: U }) | null>;
|
|
417
|
-
confirm(token: string): Promise<Answer>;
|
|
436
|
+
confirm(token: string, options?: SignInOptions): Promise<Answer>;
|
|
418
437
|
};
|
|
419
438
|
}
|
|
420
439
|
```
|
|
421
440
|
|
|
422
441
|
`Answer` is `SignInResult<U>` on a type with a password in an instance given
|
|
423
|
-
a `secondFactor`, and `SignedIn<U>` everywhere else. `MagicLinkApi
|
|
424
|
-
`IssuedToken` are exported from `@nxgt/janus`, as
|
|
442
|
+
a `secondFactor`, and `SignedIn<U>` everywhere else. `MagicLinkApi`,
|
|
443
|
+
`IssuedToken` and `SignInOptions` are exported from `@nxgt/janus`, as
|
|
444
|
+
types; `SignInOptions` is `{ device?: string | null }` — see
|
|
445
|
+
[devices](devices.md#giving-a-sign-in-the-device).
|
|
425
446
|
|
|
426
447
|
## For an adapter
|
|
427
448
|
|
|
@@ -437,6 +458,7 @@ holds that a link and a sign-in code are never redeemed as each other. See
|
|
|
437
458
|
- [The second factor](second-factor.md) — the challenge `confirm` answers for a user whose factor is active
|
|
438
459
|
- [E-mail verification and password reset](email-flows.md) — the other flows that send a link, and `TOKEN_STALE`
|
|
439
460
|
- [Sessions](sessions.md) — the cookie the session is sent in
|
|
461
|
+
- [Devices](devices.md) — the `device` `confirm` takes, and the notice of a new one
|
|
440
462
|
- [Errors](errors.md) — every code and its status
|
|
441
463
|
- [Troubleshooting](../troubleshooting.md#sign-in-links) — by the message you see
|
|
442
464
|
- [`@nxgt/janus-telemetry`](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus-telemetry/docs/guide/tracing.md#a-link-sent-by-e-mail) — the events a sign-in by link writes
|