@vex-chat/spire 1.11.4 → 2.0.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 +9 -7
- package/dist/ClientManager.d.ts +2 -1
- package/dist/ClientManager.js +4 -1
- package/dist/ClientManager.js.map +1 -1
- package/dist/Database.d.ts +12 -0
- package/dist/Database.js +100 -16
- package/dist/Database.js.map +1 -1
- package/dist/NotificationService.d.ts +4 -0
- package/dist/NotificationService.js +54 -10
- package/dist/NotificationService.js.map +1 -1
- package/dist/Spire.d.ts +3 -0
- package/dist/Spire.js +158 -4
- package/dist/Spire.js.map +1 -1
- package/dist/migrations/2026-05-03_passkeys.js +2 -2
- package/dist/migrations/2026-05-03_passkeys.js.map +1 -1
- package/dist/server/index.d.ts +2 -2
- package/dist/server/index.js +18 -4
- package/dist/server/index.js.map +1 -1
- package/dist/server/passkey.js +24 -16
- package/dist/server/passkey.js.map +1 -1
- package/dist/server/passkeyDevices.d.ts +2 -2
- package/dist/server/passkeyDevices.js +14 -15
- package/dist/server/passkeyDevices.js.map +1 -1
- package/dist/server/rateLimit.d.ts +6 -2
- package/dist/server/rateLimit.js +13 -10
- package/dist/server/rateLimit.js.map +1 -1
- package/dist/server/user.d.ts +14 -0
- package/dist/server/user.js +48 -0
- package/dist/server/user.js.map +1 -1
- package/dist/utils/loadEnv.d.ts +3 -0
- package/dist/utils/loadEnv.js +63 -3
- package/dist/utils/loadEnv.js.map +1 -1
- package/package.json +6 -6
- package/src/ClientManager.ts +7 -0
- package/src/Database.ts +136 -16
- package/src/NotificationService.ts +84 -11
- package/src/Spire.ts +233 -2
- package/src/__tests__/Database.spec.ts +191 -2
- package/src/__tests__/loadEnv.spec.ts +91 -0
- package/src/__tests__/notifyFanout.spec.ts +109 -0
- package/src/__tests__/passkeys.spec.ts +46 -0
- package/src/__tests__/rateLimit.spec.ts +60 -0
- package/src/migrations/2026-05-03_passkeys.ts +2 -2
- package/src/server/index.ts +26 -3
- package/src/server/passkey.ts +25 -17
- package/src/server/passkeyDevices.ts +17 -14
- package/src/server/rateLimit.ts +18 -10
- package/src/server/user.ts +66 -0
- package/src/utils/loadEnv.ts +79 -3
|
@@ -36,11 +36,13 @@ function createSpireHarness(
|
|
|
36
36
|
clients: FakeClient[],
|
|
37
37
|
subscriptions: NotificationSubscription[] = [],
|
|
38
38
|
removeNotificationSubscription = vi.fn(() => Promise.resolve(true)),
|
|
39
|
+
hasMail = vi.fn(() => Promise.resolve(true)),
|
|
39
40
|
) {
|
|
40
41
|
const retrieveNotificationSubscriptions = vi.fn(() =>
|
|
41
42
|
Promise.resolve(subscriptions),
|
|
42
43
|
);
|
|
43
44
|
const db = {
|
|
45
|
+
hasMail,
|
|
44
46
|
removeNotificationSubscription,
|
|
45
47
|
retrieveNotificationSubscriptions,
|
|
46
48
|
} as unknown as Database;
|
|
@@ -53,6 +55,7 @@ function createSpireHarness(
|
|
|
53
55
|
return {
|
|
54
56
|
clients: managers,
|
|
55
57
|
db,
|
|
58
|
+
hasMail,
|
|
56
59
|
notifications,
|
|
57
60
|
removeNotificationSubscription,
|
|
58
61
|
retrieveNotificationSubscriptions,
|
|
@@ -199,6 +202,112 @@ describe("Spire notify fanout", () => {
|
|
|
199
202
|
});
|
|
200
203
|
});
|
|
201
204
|
|
|
205
|
+
it("skips Expo mail push when websocket delivery is acknowledged during the grace window", async () => {
|
|
206
|
+
vi.useFakeTimers();
|
|
207
|
+
const recipient = fakeClient({
|
|
208
|
+
getDeviceID: vi.fn(() => "device-b"),
|
|
209
|
+
});
|
|
210
|
+
const fetchMock = vi.fn();
|
|
211
|
+
const hasMail = vi.fn(() => Promise.resolve(false));
|
|
212
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
213
|
+
|
|
214
|
+
const { notifications } = createSpireHarness(
|
|
215
|
+
[recipient],
|
|
216
|
+
[subscription],
|
|
217
|
+
undefined,
|
|
218
|
+
hasMail,
|
|
219
|
+
);
|
|
220
|
+
const mailNonce = new Uint8Array([1, 2, 3]);
|
|
221
|
+
|
|
222
|
+
notifications.notify({
|
|
223
|
+
deviceID: "device-b",
|
|
224
|
+
event: "mail",
|
|
225
|
+
mailNonce,
|
|
226
|
+
transmissionID: "00000000-0000-0000-0000-000000000010",
|
|
227
|
+
userID: "user-b",
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
expect(recipient.send).toHaveBeenCalledTimes(1);
|
|
231
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
232
|
+
|
|
233
|
+
await vi.advanceTimersByTimeAsync(1500);
|
|
234
|
+
|
|
235
|
+
await vi.waitFor(() => {
|
|
236
|
+
expect(hasMail).toHaveBeenCalledWith(mailNonce, "device-b");
|
|
237
|
+
});
|
|
238
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("sends Expo mail push after the grace window when websocket mail remains pending", async () => {
|
|
242
|
+
vi.useFakeTimers();
|
|
243
|
+
const recipient = fakeClient({
|
|
244
|
+
getDeviceID: vi.fn(() => "device-b"),
|
|
245
|
+
});
|
|
246
|
+
const fetchMock = vi.fn().mockResolvedValueOnce({
|
|
247
|
+
json: () =>
|
|
248
|
+
Promise.resolve({
|
|
249
|
+
data: [{ id: "receipt-a", status: "ok" }],
|
|
250
|
+
}),
|
|
251
|
+
ok: true,
|
|
252
|
+
});
|
|
253
|
+
const hasMail = vi.fn(() => Promise.resolve(true));
|
|
254
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
255
|
+
|
|
256
|
+
const { notifications } = createSpireHarness(
|
|
257
|
+
[recipient],
|
|
258
|
+
[subscription],
|
|
259
|
+
undefined,
|
|
260
|
+
hasMail,
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
notifications.notify({
|
|
264
|
+
deviceID: "device-b",
|
|
265
|
+
event: "mail",
|
|
266
|
+
mailNonce: new Uint8Array([4, 5, 6]),
|
|
267
|
+
transmissionID: "00000000-0000-0000-0000-000000000011",
|
|
268
|
+
userID: "user-b",
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
272
|
+
await vi.advanceTimersByTimeAsync(1500);
|
|
273
|
+
|
|
274
|
+
await vi.waitFor(() => {
|
|
275
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("sends Expo mail push immediately when no websocket client was notified", async () => {
|
|
280
|
+
const fetchMock = vi.fn().mockResolvedValueOnce({
|
|
281
|
+
json: () =>
|
|
282
|
+
Promise.resolve({
|
|
283
|
+
data: [{ id: "receipt-a", status: "ok" }],
|
|
284
|
+
}),
|
|
285
|
+
ok: true,
|
|
286
|
+
});
|
|
287
|
+
const hasMail = vi.fn(() => Promise.resolve(true));
|
|
288
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
289
|
+
|
|
290
|
+
const { notifications } = createSpireHarness(
|
|
291
|
+
[],
|
|
292
|
+
[subscription],
|
|
293
|
+
undefined,
|
|
294
|
+
hasMail,
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
notifications.notify({
|
|
298
|
+
deviceID: "device-b",
|
|
299
|
+
event: "mail",
|
|
300
|
+
mailNonce: new Uint8Array([7, 8, 9]),
|
|
301
|
+
transmissionID: "00000000-0000-0000-0000-000000000012",
|
|
302
|
+
userID: "user-b",
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
await vi.waitFor(() => {
|
|
306
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
307
|
+
});
|
|
308
|
+
expect(hasMail).not.toHaveBeenCalled();
|
|
309
|
+
});
|
|
310
|
+
|
|
202
311
|
it("checks Expo receipts and removes unregistered devices", async () => {
|
|
203
312
|
vi.useFakeTimers();
|
|
204
313
|
const fetchMock = vi
|
|
@@ -9,6 +9,7 @@ import type { SpireOptions } from "../Spire.ts";
|
|
|
9
9
|
import { describe, expect, it, vi } from "vitest";
|
|
10
10
|
|
|
11
11
|
import { Database } from "../Database.ts";
|
|
12
|
+
import { passkeySecondFactorError } from "../Spire.ts";
|
|
12
13
|
|
|
13
14
|
vi.mock("uuid", () => ({
|
|
14
15
|
parse: (s: string) => {
|
|
@@ -191,6 +192,51 @@ describe("Database passkeys", () => {
|
|
|
191
192
|
});
|
|
192
193
|
});
|
|
193
194
|
|
|
195
|
+
describe("passkeySecondFactorError", () => {
|
|
196
|
+
it("allows bootstrap login when the account has no passkeys", async () => {
|
|
197
|
+
expect.assertions(1);
|
|
198
|
+
await withDb(async (db) => {
|
|
199
|
+
await expect(
|
|
200
|
+
passkeySecondFactorError(db, userID, undefined, "mismatch"),
|
|
201
|
+
).resolves.toBeNull();
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("requires a matching passkey once the account has one", async () => {
|
|
206
|
+
expect.assertions(3);
|
|
207
|
+
await withDb(async (db) => {
|
|
208
|
+
const created = await db.createPasskey(
|
|
209
|
+
userID,
|
|
210
|
+
samplePasskey.name,
|
|
211
|
+
samplePasskey.credentialID,
|
|
212
|
+
samplePasskey.publicKeyHex,
|
|
213
|
+
samplePasskey.algorithm,
|
|
214
|
+
samplePasskey.transports,
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
await expect(
|
|
218
|
+
passkeySecondFactorError(db, userID, undefined, "mismatch"),
|
|
219
|
+
).resolves.toBe("Passkey verification required.");
|
|
220
|
+
await expect(
|
|
221
|
+
passkeySecondFactorError(
|
|
222
|
+
db,
|
|
223
|
+
userID,
|
|
224
|
+
"not-this-passkey",
|
|
225
|
+
"mismatch",
|
|
226
|
+
),
|
|
227
|
+
).resolves.toBe("mismatch");
|
|
228
|
+
await expect(
|
|
229
|
+
passkeySecondFactorError(
|
|
230
|
+
db,
|
|
231
|
+
userID,
|
|
232
|
+
created.passkeyID,
|
|
233
|
+
"mismatch",
|
|
234
|
+
),
|
|
235
|
+
).resolves.toBeNull();
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
194
240
|
describe("deletePasskey", () => {
|
|
195
241
|
it("removes the passkey row", async () => {
|
|
196
242
|
expect.assertions(2);
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2020-2026 Vex Heavy Industries LLC
|
|
3
|
+
* Licensed under AGPL-3.0. See LICENSE for details.
|
|
4
|
+
* Commercial licenses available at vex.wtf
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
DEV_API_KEY_HEADER,
|
|
11
|
+
devApiKeyMatches,
|
|
12
|
+
devApiKeySkipsRateLimits,
|
|
13
|
+
} from "../server/rateLimit.ts";
|
|
14
|
+
|
|
15
|
+
const ORIGINAL_DEV_API_KEY = process.env["DEV_API_KEY"];
|
|
16
|
+
const ORIGINAL_DISABLE_RATE_LIMITS = process.env["SPIRE_DISABLE_RATE_LIMITS"];
|
|
17
|
+
|
|
18
|
+
function requestWithDevKey(value: string | undefined) {
|
|
19
|
+
return {
|
|
20
|
+
get(name: string): string | undefined {
|
|
21
|
+
return name.toLowerCase() === DEV_API_KEY_HEADER
|
|
22
|
+
? value
|
|
23
|
+
: undefined;
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("dev API key matching", () => {
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
if (ORIGINAL_DEV_API_KEY === undefined) {
|
|
31
|
+
delete process.env["DEV_API_KEY"];
|
|
32
|
+
} else {
|
|
33
|
+
process.env["DEV_API_KEY"] = ORIGINAL_DEV_API_KEY;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (ORIGINAL_DISABLE_RATE_LIMITS === undefined) {
|
|
37
|
+
delete process.env["SPIRE_DISABLE_RATE_LIMITS"];
|
|
38
|
+
} else {
|
|
39
|
+
process.env["SPIRE_DISABLE_RATE_LIMITS"] =
|
|
40
|
+
ORIGINAL_DISABLE_RATE_LIMITS;
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("matches the configured dev API key exactly", () => {
|
|
45
|
+
process.env["DEV_API_KEY"] = "test-secret";
|
|
46
|
+
|
|
47
|
+
expect(devApiKeyMatches(requestWithDevKey("test-secret"))).toBe(true);
|
|
48
|
+
expect(devApiKeyMatches(requestWithDevKey("wrong-secret"))).toBe(false);
|
|
49
|
+
expect(devApiKeyMatches(requestWithDevKey(undefined))).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("does not treat disabled rate limits as a dev API key match", () => {
|
|
53
|
+
delete process.env["DEV_API_KEY"];
|
|
54
|
+
process.env["SPIRE_DISABLE_RATE_LIMITS"] = "1";
|
|
55
|
+
|
|
56
|
+
const req = requestWithDevKey(undefined);
|
|
57
|
+
expect(devApiKeyMatches(req)).toBe(false);
|
|
58
|
+
expect(devApiKeySkipsRateLimits(req)).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -13,8 +13,8 @@ export async function down(db: Kysely<unknown>): Promise<void> {
|
|
|
13
13
|
// Passkeys are an administrative second-class credential alongside
|
|
14
14
|
// `devices`. They cannot send/decrypt mail (no ratchet keys), but they
|
|
15
15
|
// can authenticate the owning user, list devices, delete devices, and
|
|
16
|
-
//
|
|
17
|
-
// recovery
|
|
16
|
+
// recover/reject pending device-enrollment requests — i.e. account
|
|
17
|
+
// recovery without additive passkey-only provisioning.
|
|
18
18
|
//
|
|
19
19
|
// `credentialID` is the WebAuthn credential id (base64url, opaque), and
|
|
20
20
|
// is unique across all passkeys. `publicKey` is the COSE_Key bytes
|
package/src/server/index.ts
CHANGED
|
@@ -42,7 +42,11 @@ import {
|
|
|
42
42
|
hasPermission,
|
|
43
43
|
userHasPermission,
|
|
44
44
|
} from "./permissions.ts";
|
|
45
|
-
import {
|
|
45
|
+
import {
|
|
46
|
+
devApiKeyMatches,
|
|
47
|
+
globalLimiter,
|
|
48
|
+
keyBundleLimiter,
|
|
49
|
+
} from "./rateLimit.ts";
|
|
46
50
|
import { getUserRouter } from "./user.ts";
|
|
47
51
|
import { censorUser, getParam, getUser } from "./utils.ts";
|
|
48
52
|
import { getWellKnownRouter } from "./wellKnown.ts";
|
|
@@ -200,7 +204,7 @@ export const protect: express.RequestHandler = (req, res, next) => {
|
|
|
200
204
|
* Restrict a route to passkey-scoped JWTs (used by the parallel
|
|
201
205
|
* `/user/:id/passkey/devices/...` admin/recovery routes). A
|
|
202
206
|
* passkey-authenticated caller can list devices, delete devices, and
|
|
203
|
-
*
|
|
207
|
+
* recover or reject pending enrollments — and nothing else.
|
|
204
208
|
*/
|
|
205
209
|
export const protectPasskey: express.RequestHandler = (req, res, next) => {
|
|
206
210
|
if (!req.user || !req.passkey) {
|
|
@@ -243,6 +247,7 @@ export const initApp = (
|
|
|
243
247
|
data?: unknown,
|
|
244
248
|
deviceID?: string,
|
|
245
249
|
) => void,
|
|
250
|
+
disconnectDevices?: (deviceIDs: string[]) => void,
|
|
246
251
|
) => {
|
|
247
252
|
// INIT ROUTERS
|
|
248
253
|
const userRouter = getUserRouter(db, tokenValidator, notify);
|
|
@@ -250,7 +255,11 @@ export const initApp = (
|
|
|
250
255
|
const avatarRouter = getAvatarRouter();
|
|
251
256
|
const inviteRouter = getInviteRouter(db, tokenValidator, notify);
|
|
252
257
|
const passkeyRouter = getPasskeyRouter(db);
|
|
253
|
-
const passkeyDeviceRouter = getPasskeyDeviceRouter(
|
|
258
|
+
const passkeyDeviceRouter = getPasskeyDeviceRouter(
|
|
259
|
+
db,
|
|
260
|
+
notify,
|
|
261
|
+
disconnectDevices,
|
|
262
|
+
);
|
|
254
263
|
|
|
255
264
|
// MIDDLEWARE
|
|
256
265
|
|
|
@@ -663,6 +672,20 @@ export const initApp = (
|
|
|
663
672
|
res.sendStatus(404);
|
|
664
673
|
return;
|
|
665
674
|
}
|
|
675
|
+
if (req.user?.userID !== device.owner) {
|
|
676
|
+
res.sendStatus(401);
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
const passkeys = await db.retrievePasskeysByUser(device.owner);
|
|
680
|
+
// The CLI stress harness cannot perform a WebAuthn ceremony.
|
|
681
|
+
// Keep normal clients gated, but let the existing dev/load-test key
|
|
682
|
+
// exercise device connect on disposable accounts.
|
|
683
|
+
if (passkeys.length === 0 && !devApiKeyMatches(req)) {
|
|
684
|
+
res.status(403).send({
|
|
685
|
+
error: "A passkey must be registered before this device can connect.",
|
|
686
|
+
});
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
666
689
|
|
|
667
690
|
const regKey = await spireXSignOpenAsync(
|
|
668
691
|
signed,
|
package/src/server/passkey.ts
CHANGED
|
@@ -192,17 +192,6 @@ export const getPasskeyRouter = (db: Database) => {
|
|
|
192
192
|
res.sendStatus(401);
|
|
193
193
|
return;
|
|
194
194
|
}
|
|
195
|
-
// Passwords/passkey JWTs both come through `protect`; only
|
|
196
|
-
// a real device session may add a passkey to keep the
|
|
197
|
-
// recovery story symmetric with device delete (a passkey
|
|
198
|
-
// can't bootstrap another passkey).
|
|
199
|
-
if (!req.device) {
|
|
200
|
-
res.status(401).send({
|
|
201
|
-
error: "Adding a passkey requires an authenticated device.",
|
|
202
|
-
});
|
|
203
|
-
return;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
195
|
const parsed = PasskeyRegistrationStartPayloadSchema.safeParse(
|
|
207
196
|
req.body,
|
|
208
197
|
);
|
|
@@ -215,6 +204,16 @@ export const getPasskeyRouter = (db: Database) => {
|
|
|
215
204
|
}
|
|
216
205
|
|
|
217
206
|
const existing = await db.retrievePasskeysByUser(userID);
|
|
207
|
+
// A freshly registered account has a bearer token but cannot
|
|
208
|
+
// get a device token until its first passkey exists. Allow
|
|
209
|
+
// exactly that bootstrap case; every later passkey addition
|
|
210
|
+
// must come from an authenticated device session.
|
|
211
|
+
if (!req.device && existing.length > 0) {
|
|
212
|
+
res.status(401).send({
|
|
213
|
+
error: "Adding another passkey requires an authenticated device.",
|
|
214
|
+
});
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
218
217
|
if (existing.length >= MAX_PASSKEYS_PER_USER) {
|
|
219
218
|
res.status(409).send({
|
|
220
219
|
error: `Each account is limited to ${MAX_PASSKEYS_PER_USER} passkeys.`,
|
|
@@ -277,12 +276,6 @@ export const getPasskeyRouter = (db: Database) => {
|
|
|
277
276
|
res.sendStatus(401);
|
|
278
277
|
return;
|
|
279
278
|
}
|
|
280
|
-
if (!req.device) {
|
|
281
|
-
res.status(401).send({
|
|
282
|
-
error: "Adding a passkey requires an authenticated device.",
|
|
283
|
-
});
|
|
284
|
-
return;
|
|
285
|
-
}
|
|
286
279
|
|
|
287
280
|
const parsed = PasskeyRegistrationFinishPayloadSchema.safeParse(
|
|
288
281
|
req.body,
|
|
@@ -295,6 +288,14 @@ export const getPasskeyRouter = (db: Database) => {
|
|
|
295
288
|
return;
|
|
296
289
|
}
|
|
297
290
|
|
|
291
|
+
const existing = await db.retrievePasskeysByUser(userID);
|
|
292
|
+
if (!req.device && existing.length > 0) {
|
|
293
|
+
res.status(401).send({
|
|
294
|
+
error: "Adding another passkey requires an authenticated device.",
|
|
295
|
+
});
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
298
299
|
pruneRegistrations();
|
|
299
300
|
const pending = pendingRegistrations.get(parsed.data.requestID);
|
|
300
301
|
if (!pending || pending.userID !== userID) {
|
|
@@ -409,6 +410,13 @@ export const getPasskeyRouter = (db: Database) => {
|
|
|
409
410
|
res.sendStatus(404);
|
|
410
411
|
return;
|
|
411
412
|
}
|
|
413
|
+
const passkeys = await db.retrievePasskeysByUser(userID);
|
|
414
|
+
if (passkeys.length <= 1) {
|
|
415
|
+
res.status(400).send({
|
|
416
|
+
error: "You can't delete your last passkey.",
|
|
417
|
+
});
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
412
420
|
await db.deletePasskey(passkeyID);
|
|
413
421
|
res.sendStatus(200);
|
|
414
422
|
},
|
|
@@ -10,7 +10,10 @@ import express from "express";
|
|
|
10
10
|
|
|
11
11
|
import { msgpack } from "../utils/msgpack.ts";
|
|
12
12
|
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
recoverDeviceEnrollmentRequest,
|
|
15
|
+
resolveDeviceEnrollmentRequest,
|
|
16
|
+
} from "./user.ts";
|
|
14
17
|
import { getParam, getUser } from "./utils.ts";
|
|
15
18
|
|
|
16
19
|
import { protectPasskey } from "./index.ts";
|
|
@@ -21,8 +24,8 @@ import { protectPasskey } from "./index.ts";
|
|
|
21
24
|
*
|
|
22
25
|
* - `GET /user/:id/passkey/devices` — list
|
|
23
26
|
* - `DELETE /user/:id/passkey/devices/:deviceID` — remove
|
|
24
|
-
* - `POST /user/:id/passkey/devices/requests/:requestID/approve` — approve
|
|
25
27
|
* - `POST /user/:id/passkey/devices/requests/:requestID/reject` — reject
|
|
28
|
+
* - `POST /user/:id/passkey/recover/devices/requests/:requestID` — recover
|
|
26
29
|
*
|
|
27
30
|
* The route family is parallel to `/user/:id/devices/...` so the
|
|
28
31
|
* existing device-authenticated flow stays untouched (and there's no
|
|
@@ -42,6 +45,7 @@ export const getPasskeyDeviceRouter = (
|
|
|
42
45
|
data?: unknown,
|
|
43
46
|
deviceID?: string,
|
|
44
47
|
) => void,
|
|
48
|
+
disconnectDevices?: (deviceIDs: string[]) => void,
|
|
45
49
|
) => {
|
|
46
50
|
const router = express.Router();
|
|
47
51
|
|
|
@@ -79,10 +83,9 @@ export const getPasskeyDeviceRouter = (
|
|
|
79
83
|
// The device-auth `DELETE /user/:id/devices/:deviceID`
|
|
80
84
|
// refuses to delete the user's last device (a device
|
|
81
85
|
// can't lock itself out). Passkeys may delete the last
|
|
82
|
-
// device on purpose: that's the
|
|
86
|
+
// device on purpose: that's part of the recovery story —
|
|
83
87
|
// "I lost my phone, sign in with the passkey, wipe the
|
|
84
|
-
// old device, then
|
|
85
|
-
// standing in as the approver."
|
|
88
|
+
// old device, then recover onto a new one."
|
|
86
89
|
await db.deleteDevice(deviceID);
|
|
87
90
|
// Tell whoever's online that the device-list shape
|
|
88
91
|
// changed; clients use this to refresh the Settings →
|
|
@@ -93,7 +96,7 @@ export const getPasskeyDeviceRouter = (
|
|
|
93
96
|
);
|
|
94
97
|
|
|
95
98
|
router.post(
|
|
96
|
-
"/user/:id/passkey/devices/requests/:requestID
|
|
99
|
+
"/user/:id/passkey/recover/devices/requests/:requestID",
|
|
97
100
|
protectPasskey,
|
|
98
101
|
async (req, res) => {
|
|
99
102
|
const userDetails = getUser(req);
|
|
@@ -103,20 +106,20 @@ export const getPasskeyDeviceRouter = (
|
|
|
103
106
|
res.sendStatus(401);
|
|
104
107
|
return;
|
|
105
108
|
}
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
|
|
112
|
-
const result = await resolveDeviceEnrollmentRequest({
|
|
113
|
-
action: "approve",
|
|
109
|
+
// Recovery is intentionally the only passkey-backed
|
|
110
|
+
// provisioning path: it provisions the pending device and
|
|
111
|
+
// revokes every previously-active device for the account in
|
|
112
|
+
// one server-side operation. Clients cannot accidentally
|
|
113
|
+
// restore an account while leaving lost devices trusted.
|
|
114
|
+
const result = await recoverDeviceEnrollmentRequest({
|
|
114
115
|
db,
|
|
115
116
|
notify,
|
|
116
117
|
requestID,
|
|
117
118
|
userID,
|
|
118
119
|
});
|
|
119
120
|
if (result.kind === "ok") {
|
|
121
|
+
notify(userID, "deviceListChanged", crypto.randomUUID());
|
|
122
|
+
disconnectDevices?.(result.revokedDeviceIDs);
|
|
120
123
|
res.send(msgpack.encode(result.device));
|
|
121
124
|
return;
|
|
122
125
|
}
|
package/src/server/rateLimit.ts
CHANGED
|
@@ -13,16 +13,11 @@ import rateLimit, { ipKeyGenerator } from "express-rate-limit";
|
|
|
13
13
|
/** HTTP header carrying the dev API key (must match {@link process.env.DEV_API_KEY}). */
|
|
14
14
|
export const DEV_API_KEY_HEADER = "x-dev-api-key";
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
*/
|
|
22
|
-
export function devApiKeySkipsRateLimits(req: Request): boolean {
|
|
23
|
-
if (disableRateLimitsByEnv()) {
|
|
24
|
-
return true;
|
|
25
|
-
}
|
|
16
|
+
interface HeaderReader {
|
|
17
|
+
get(name: string): string | undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function devApiKeyMatches(req: HeaderReader): boolean {
|
|
26
21
|
const configured = process.env["DEV_API_KEY"]?.trim() ?? "";
|
|
27
22
|
if (configured.length === 0) {
|
|
28
23
|
return false;
|
|
@@ -41,6 +36,19 @@ export function devApiKeySkipsRateLimits(req: Request): boolean {
|
|
|
41
36
|
}
|
|
42
37
|
}
|
|
43
38
|
|
|
39
|
+
/**
|
|
40
|
+
* When `DEV_API_KEY` is set in the environment, any request whose
|
|
41
|
+
* `x-dev-api-key` header matches (constant-time) skips all in-process rate
|
|
42
|
+
* limiters. Dev / load-testing escape hatch only — never set in production.
|
|
43
|
+
* (Future: first-class API keys with scopes may reuse this header name.)
|
|
44
|
+
*/
|
|
45
|
+
export function devApiKeySkipsRateLimits(req: HeaderReader): boolean {
|
|
46
|
+
if (disableRateLimitsByEnv()) {
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
return devApiKeyMatches(req);
|
|
50
|
+
}
|
|
51
|
+
|
|
44
52
|
function disableRateLimitsByEnv(): boolean {
|
|
45
53
|
const raw = process.env["SPIRE_DISABLE_RATE_LIMITS"]?.trim().toLowerCase();
|
|
46
54
|
return raw === "1" || raw === "true";
|
package/src/server/user.ts
CHANGED
|
@@ -126,6 +126,72 @@ export function createPendingDeviceEnrollmentRequest(
|
|
|
126
126
|
* The device-auth caller is expected to have already verified the
|
|
127
127
|
* approving device's signature before invoking this helper.
|
|
128
128
|
*/
|
|
129
|
+
export async function recoverDeviceEnrollmentRequest(args: {
|
|
130
|
+
db: Database;
|
|
131
|
+
notify: (
|
|
132
|
+
userID: string,
|
|
133
|
+
event: string,
|
|
134
|
+
transmissionID: string,
|
|
135
|
+
data?: unknown,
|
|
136
|
+
deviceID?: string,
|
|
137
|
+
) => void;
|
|
138
|
+
requestID: string;
|
|
139
|
+
userID: string;
|
|
140
|
+
}): Promise<
|
|
141
|
+
| { device: Device; kind: "ok"; revokedDeviceIDs: string[] }
|
|
142
|
+
| { error: string; kind: "err"; status: number }
|
|
143
|
+
> {
|
|
144
|
+
pruneDeviceEnrollmentRequests();
|
|
145
|
+
const pending = deviceEnrollments.get(args.requestID);
|
|
146
|
+
if (!pending || pending.userID !== args.userID) {
|
|
147
|
+
return { error: "Request not found.", kind: "err", status: 404 };
|
|
148
|
+
}
|
|
149
|
+
if (pending.status !== "pending") {
|
|
150
|
+
return {
|
|
151
|
+
error: "Request is not pending.",
|
|
152
|
+
kind: "err",
|
|
153
|
+
status: 409,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
if (Date.now() - pending.createdAt > DEVICE_REQUEST_TTL_MS) {
|
|
157
|
+
pending.status = "expired";
|
|
158
|
+
pending.resolvedAt = Date.now();
|
|
159
|
+
pending.error = "Request expired.";
|
|
160
|
+
deviceEnrollments.set(args.requestID, pending);
|
|
161
|
+
return { error: "Request expired.", kind: "err", status: 410 };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
const { device, revokedDeviceIDs } = await args.db.recoverDevice(
|
|
166
|
+
args.userID,
|
|
167
|
+
pending.devicePayload,
|
|
168
|
+
);
|
|
169
|
+
pending.status = "approved";
|
|
170
|
+
pending.approvedDeviceID = device.deviceID;
|
|
171
|
+
pending.resolvedAt = Date.now();
|
|
172
|
+
deviceEnrollments.set(args.requestID, pending);
|
|
173
|
+
args.notify(args.userID, "deviceRequest", crypto.randomUUID(), {
|
|
174
|
+
requestID: args.requestID,
|
|
175
|
+
status: "approved",
|
|
176
|
+
});
|
|
177
|
+
return { device, kind: "ok", revokedDeviceIDs };
|
|
178
|
+
} catch {
|
|
179
|
+
pending.status = "rejected";
|
|
180
|
+
pending.error = "Could not recover device.";
|
|
181
|
+
pending.resolvedAt = Date.now();
|
|
182
|
+
deviceEnrollments.set(args.requestID, pending);
|
|
183
|
+
args.notify(args.userID, "deviceRequest", crypto.randomUUID(), {
|
|
184
|
+
requestID: args.requestID,
|
|
185
|
+
status: "rejected",
|
|
186
|
+
});
|
|
187
|
+
return {
|
|
188
|
+
error: "Could not recover device.",
|
|
189
|
+
kind: "err",
|
|
190
|
+
status: 470,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
129
195
|
export async function resolveDeviceEnrollmentRequest(args: {
|
|
130
196
|
action: "approve" | "reject";
|
|
131
197
|
db: Database;
|