@vex-chat/spire 1.3.6 → 1.4.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/dist/Database.d.ts +36 -1
- package/dist/Database.js +109 -0
- package/dist/Database.js.map +1 -1
- package/dist/Spire.d.ts +7 -0
- package/dist/Spire.js +7 -0
- package/dist/Spire.js.map +1 -1
- package/dist/db/schema.d.ts +16 -0
- package/dist/migrations/2026-05-03_passkeys.d.ts +8 -0
- package/dist/migrations/2026-05-03_passkeys.js +44 -0
- package/dist/migrations/2026-05-03_passkeys.js.map +1 -0
- package/dist/server/index.d.ts +7 -0
- package/dist/server/index.js +52 -6
- package/dist/server/index.js.map +1 -1
- package/dist/server/passkey.d.ts +7 -0
- package/dist/server/passkey.js +449 -0
- package/dist/server/passkey.js.map +1 -0
- package/dist/server/passkeyDevices.d.ts +25 -0
- package/dist/server/passkeyDevices.js +118 -0
- package/dist/server/passkeyDevices.js.map +1 -0
- package/dist/server/user.d.ts +27 -1
- package/dist/server/user.js +92 -0
- package/dist/server/user.js.map +1 -1
- package/dist/types/express.d.ts +10 -0
- package/dist/types/express.js.map +1 -1
- package/package.json +6 -5
- package/src/Database.ts +134 -1
- package/src/Spire.ts +7 -0
- package/src/__tests__/passkeys.spec.ts +217 -0
- package/src/db/schema.ts +18 -1
- package/src/migrations/2026-05-03_passkeys.ts +52 -0
- package/src/server/index.ts +55 -6
- package/src/server/passkey.ts +589 -0
- package/src/server/passkeyDevices.ts +154 -0
- package/src/server/user.ts +114 -1
- package/src/types/express.ts +8 -0
|
@@ -0,0 +1,589 @@
|
|
|
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 type { Database } from "../Database.ts";
|
|
8
|
+
import type {
|
|
9
|
+
AuthenticationResponseJSON,
|
|
10
|
+
AuthenticatorTransportFuture,
|
|
11
|
+
RegistrationResponseJSON,
|
|
12
|
+
} from "@simplewebauthn/server";
|
|
13
|
+
import type { Passkey } from "@vex-chat/types";
|
|
14
|
+
|
|
15
|
+
import express from "express";
|
|
16
|
+
|
|
17
|
+
import { XUtils } from "@vex-chat/crypto";
|
|
18
|
+
import {
|
|
19
|
+
PasskeyAuthFinishPayloadSchema,
|
|
20
|
+
PasskeyAuthStartPayloadSchema,
|
|
21
|
+
PasskeyRegistrationFinishPayloadSchema,
|
|
22
|
+
PasskeyRegistrationStartPayloadSchema,
|
|
23
|
+
} from "@vex-chat/types";
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
generateAuthenticationOptions,
|
|
27
|
+
generateRegistrationOptions,
|
|
28
|
+
verifyAuthenticationResponse,
|
|
29
|
+
verifyRegistrationResponse,
|
|
30
|
+
} from "@simplewebauthn/server";
|
|
31
|
+
import jwt from "jsonwebtoken";
|
|
32
|
+
|
|
33
|
+
import { JWT_EXPIRY_PASSKEY } from "../Spire.ts";
|
|
34
|
+
import { getJwtSecret } from "../utils/jwtSecret.ts";
|
|
35
|
+
import { msgpack } from "../utils/msgpack.ts";
|
|
36
|
+
|
|
37
|
+
import { AppError } from "./errors.ts";
|
|
38
|
+
import { authLimiter } from "./rateLimit.ts";
|
|
39
|
+
import { censorUser, getParam, getUser } from "./utils.ts";
|
|
40
|
+
|
|
41
|
+
import { protect } from "./index.ts";
|
|
42
|
+
|
|
43
|
+
const REGISTRATION_TTL_MS = 5 * 60 * 1000; // 5 min
|
|
44
|
+
const AUTHENTICATION_TTL_MS = 5 * 60 * 1000;
|
|
45
|
+
// Cap each user's passkey count so a compromised JWT can't fill the
|
|
46
|
+
// table. WebAuthn-style apps typically allow ~20; we go conservative.
|
|
47
|
+
const MAX_PASSKEYS_PER_USER = 10;
|
|
48
|
+
|
|
49
|
+
interface PendingAuthentication {
|
|
50
|
+
challenge: string;
|
|
51
|
+
createdAt: number;
|
|
52
|
+
userID: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface PendingRegistration {
|
|
56
|
+
challenge: string;
|
|
57
|
+
createdAt: number;
|
|
58
|
+
name: string;
|
|
59
|
+
userID: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const pendingRegistrations = new Map<string, PendingRegistration>();
|
|
63
|
+
const pendingAuthentications = new Map<string, PendingAuthentication>();
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Returns the WebAuthn relying-party config from the environment.
|
|
67
|
+
*
|
|
68
|
+
* - `SPIRE_PASSKEY_RP_ID` — RP ID (eTLD+1 of the user-facing host the
|
|
69
|
+
* client is loaded from, e.g. `vex.wtf` or `localhost`). Required.
|
|
70
|
+
* - `SPIRE_PASSKEY_RP_NAME` — display name for prompts. Defaults to
|
|
71
|
+
* "Vex".
|
|
72
|
+
* - `SPIRE_PASSKEY_ORIGINS` — comma-separated allowlist of expected
|
|
73
|
+
* client origins (e.g. `https://app.vex.wtf,tauri://localhost,
|
|
74
|
+
* http://localhost:5173`). Required: WebAuthn binds an assertion
|
|
75
|
+
* to its origin and we must check it explicitly.
|
|
76
|
+
*/
|
|
77
|
+
function getRpConfig(): {
|
|
78
|
+
expectedOrigin: string[];
|
|
79
|
+
rpID: string;
|
|
80
|
+
rpName: string;
|
|
81
|
+
} {
|
|
82
|
+
const rpID = process.env["SPIRE_PASSKEY_RP_ID"]?.trim();
|
|
83
|
+
const originsRaw = process.env["SPIRE_PASSKEY_ORIGINS"]?.trim();
|
|
84
|
+
if (!rpID) {
|
|
85
|
+
throw new AppError(
|
|
86
|
+
500,
|
|
87
|
+
"Passkeys are not configured on this server (SPIRE_PASSKEY_RP_ID is unset).",
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
if (!originsRaw) {
|
|
91
|
+
throw new AppError(
|
|
92
|
+
500,
|
|
93
|
+
"Passkeys are not configured on this server (SPIRE_PASSKEY_ORIGINS is unset).",
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
const expectedOrigin = originsRaw
|
|
97
|
+
.split(",")
|
|
98
|
+
.map((o) => o.trim())
|
|
99
|
+
.filter((o) => o.length > 0);
|
|
100
|
+
if (expectedOrigin.length === 0) {
|
|
101
|
+
throw new AppError(500, "SPIRE_PASSKEY_ORIGINS is empty.");
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
expectedOrigin,
|
|
105
|
+
rpID,
|
|
106
|
+
rpName: process.env["SPIRE_PASSKEY_RP_NAME"]?.trim() || "Vex",
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function pruneAuthentications(nowMs = Date.now()): void {
|
|
111
|
+
for (const [id, entry] of pendingAuthentications.entries()) {
|
|
112
|
+
if (nowMs - entry.createdAt > AUTHENTICATION_TTL_MS) {
|
|
113
|
+
pendingAuthentications.delete(id);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function pruneRegistrations(nowMs = Date.now()): void {
|
|
119
|
+
for (const [id, entry] of pendingRegistrations.entries()) {
|
|
120
|
+
if (nowMs - entry.createdAt > REGISTRATION_TTL_MS) {
|
|
121
|
+
pendingRegistrations.delete(id);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const KNOWN_TRANSPORTS = [
|
|
127
|
+
"ble",
|
|
128
|
+
"cable",
|
|
129
|
+
"hybrid",
|
|
130
|
+
"internal",
|
|
131
|
+
"nfc",
|
|
132
|
+
"smart-card",
|
|
133
|
+
"usb",
|
|
134
|
+
] as const satisfies readonly AuthenticatorTransportFuture[];
|
|
135
|
+
|
|
136
|
+
function isKnownTransport(s: string): s is AuthenticatorTransportFuture {
|
|
137
|
+
return (KNOWN_TRANSPORTS as readonly string[]).includes(s);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function sanitizeTransports(input: string[]): AuthenticatorTransportFuture[] {
|
|
141
|
+
return input.filter(isKnownTransport);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Issues a passkey-scoped JWT.
|
|
146
|
+
*
|
|
147
|
+
* Carries `scope: "passkey"` and the owning userID; deliberately
|
|
148
|
+
* shorter-lived than a device JWT (5 min vs 7 days) because a
|
|
149
|
+
* passkey JWT grants destructive admin powers (delete a device,
|
|
150
|
+
* approve an enrollment) without further user verification. Callers
|
|
151
|
+
* re-do the WebAuthn ceremony when this expires.
|
|
152
|
+
*/
|
|
153
|
+
function signPasskeyToken(args: {
|
|
154
|
+
passkeyID: string;
|
|
155
|
+
user: ReturnType<typeof censorUser>;
|
|
156
|
+
}): string {
|
|
157
|
+
return jwt.sign(
|
|
158
|
+
{
|
|
159
|
+
passkey: { passkeyID: args.passkeyID },
|
|
160
|
+
scope: "passkey" as const,
|
|
161
|
+
user: args.user,
|
|
162
|
+
},
|
|
163
|
+
getJwtSecret(),
|
|
164
|
+
{ expiresIn: JWT_EXPIRY_PASSKEY },
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export const getPasskeyRouter = (db: Database) => {
|
|
169
|
+
const router = express.Router();
|
|
170
|
+
|
|
171
|
+
// ── Authenticated registration (an existing device adds a passkey) ──
|
|
172
|
+
|
|
173
|
+
router.post(
|
|
174
|
+
"/user/:id/passkeys/register/begin",
|
|
175
|
+
protect,
|
|
176
|
+
async (req, res) => {
|
|
177
|
+
const userDetails = getUser(req);
|
|
178
|
+
const userID = getParam(req, "id");
|
|
179
|
+
if (userDetails.userID !== userID) {
|
|
180
|
+
res.sendStatus(401);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
// Passwords/passkey JWTs both come through `protect`; only
|
|
184
|
+
// a real device session may add a passkey to keep the
|
|
185
|
+
// recovery story symmetric with device delete (a passkey
|
|
186
|
+
// can't bootstrap another passkey).
|
|
187
|
+
if (!req.device) {
|
|
188
|
+
res.status(401).send({
|
|
189
|
+
error: "Adding a passkey requires an authenticated device.",
|
|
190
|
+
});
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const parsed = PasskeyRegistrationStartPayloadSchema.safeParse(
|
|
195
|
+
req.body,
|
|
196
|
+
);
|
|
197
|
+
if (!parsed.success) {
|
|
198
|
+
res.status(400).json({
|
|
199
|
+
error: "Invalid registration payload",
|
|
200
|
+
issues: parsed.error.issues,
|
|
201
|
+
});
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const existing = await db.retrievePasskeysByUser(userID);
|
|
206
|
+
if (existing.length >= MAX_PASSKEYS_PER_USER) {
|
|
207
|
+
res.status(409).send({
|
|
208
|
+
error: `Each account is limited to ${MAX_PASSKEYS_PER_USER} passkeys.`,
|
|
209
|
+
});
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const { rpID, rpName } = getRpConfig();
|
|
214
|
+
// The userID we hand to the authenticator is the account
|
|
215
|
+
// userID encoded as bytes; this scopes the credential to
|
|
216
|
+
// the account so re-registering on the same authenticator
|
|
217
|
+
// updates the existing credential instead of stacking.
|
|
218
|
+
const userIDBytes = new TextEncoder().encode(userID);
|
|
219
|
+
|
|
220
|
+
const options = await generateRegistrationOptions({
|
|
221
|
+
attestationType: "none",
|
|
222
|
+
authenticatorSelection: {
|
|
223
|
+
requireResidentKey: false,
|
|
224
|
+
residentKey: "preferred",
|
|
225
|
+
userVerification: "preferred",
|
|
226
|
+
},
|
|
227
|
+
excludeCredentials: [],
|
|
228
|
+
rpID,
|
|
229
|
+
rpName,
|
|
230
|
+
userDisplayName: userDetails.username,
|
|
231
|
+
userID: userIDBytes,
|
|
232
|
+
userName: userDetails.username,
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
pruneRegistrations();
|
|
236
|
+
const requestID = crypto.randomUUID();
|
|
237
|
+
pendingRegistrations.set(requestID, {
|
|
238
|
+
challenge: options.challenge,
|
|
239
|
+
createdAt: Date.now(),
|
|
240
|
+
name: parsed.data.name,
|
|
241
|
+
userID,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// PasskeyRegistrationOptions in @vex-chat/types uses a
|
|
245
|
+
// looser interface than @simplewebauthn/server (so this
|
|
246
|
+
// shared types package doesn't take a runtime dep on
|
|
247
|
+
// SimpleWebAuthn). The wire shape is identical — both
|
|
248
|
+
// sides hand the JSON straight to navigator.credentials.
|
|
249
|
+
res.send(
|
|
250
|
+
msgpack.encode({
|
|
251
|
+
options,
|
|
252
|
+
requestID,
|
|
253
|
+
}),
|
|
254
|
+
);
|
|
255
|
+
},
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
router.post(
|
|
259
|
+
"/user/:id/passkeys/register/finish",
|
|
260
|
+
protect,
|
|
261
|
+
async (req, res) => {
|
|
262
|
+
const userDetails = getUser(req);
|
|
263
|
+
const userID = getParam(req, "id");
|
|
264
|
+
if (userDetails.userID !== userID) {
|
|
265
|
+
res.sendStatus(401);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (!req.device) {
|
|
269
|
+
res.status(401).send({
|
|
270
|
+
error: "Adding a passkey requires an authenticated device.",
|
|
271
|
+
});
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const parsed = PasskeyRegistrationFinishPayloadSchema.safeParse(
|
|
276
|
+
req.body,
|
|
277
|
+
);
|
|
278
|
+
if (!parsed.success) {
|
|
279
|
+
res.status(400).json({
|
|
280
|
+
error: "Invalid finish payload",
|
|
281
|
+
issues: parsed.error.issues,
|
|
282
|
+
});
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
pruneRegistrations();
|
|
287
|
+
const pending = pendingRegistrations.get(parsed.data.requestID);
|
|
288
|
+
if (!pending || pending.userID !== userID) {
|
|
289
|
+
res.status(404).send({
|
|
290
|
+
error: "Registration request not found or expired.",
|
|
291
|
+
});
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
// Single-use challenge: clear immediately so a replay can't
|
|
295
|
+
// re-bind the credential to a second name.
|
|
296
|
+
pendingRegistrations.delete(parsed.data.requestID);
|
|
297
|
+
|
|
298
|
+
const { expectedOrigin, rpID } = getRpConfig();
|
|
299
|
+
|
|
300
|
+
let verification;
|
|
301
|
+
try {
|
|
302
|
+
// The browser's RegistrationResponseJSON is opaque to
|
|
303
|
+
// spire — simplewebauthn does the full structural
|
|
304
|
+
// decode + signature verification on this argument,
|
|
305
|
+
// so the cast from a generic `Record<string, unknown>`
|
|
306
|
+
// to the branded type is the trust boundary.
|
|
307
|
+
const rawResponse = parsed.data.response as unknown;
|
|
308
|
+
verification = await verifyRegistrationResponse({
|
|
309
|
+
expectedChallenge: pending.challenge,
|
|
310
|
+
expectedOrigin,
|
|
311
|
+
expectedRPID: rpID,
|
|
312
|
+
requireUserVerification: false,
|
|
313
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- structurally validated by simplewebauthn below
|
|
314
|
+
response: rawResponse as RegistrationResponseJSON,
|
|
315
|
+
});
|
|
316
|
+
} catch (err: unknown) {
|
|
317
|
+
const message =
|
|
318
|
+
err instanceof Error ? err.message : String(err);
|
|
319
|
+
res.status(400).send({
|
|
320
|
+
error: "Passkey attestation invalid: " + message,
|
|
321
|
+
});
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (!verification.verified) {
|
|
326
|
+
res.status(400).send({ error: "Passkey attestation failed." });
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const credential = verification.registrationInfo.credential;
|
|
331
|
+
|
|
332
|
+
const dupe = await db.retrievePasskeyByCredentialID(credential.id);
|
|
333
|
+
if (dupe) {
|
|
334
|
+
res.status(409).send({
|
|
335
|
+
error: "This authenticator is already registered.",
|
|
336
|
+
});
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const transports = sanitizeTransports(credential.transports ?? []);
|
|
341
|
+
|
|
342
|
+
// Re-check the per-user cap inside the finish step in case
|
|
343
|
+
// a concurrent request just consumed the last available
|
|
344
|
+
// slot. `MAX_PASSKEYS_PER_USER` is the source of truth.
|
|
345
|
+
const after = await db.retrievePasskeysByUser(userID);
|
|
346
|
+
if (after.length >= MAX_PASSKEYS_PER_USER) {
|
|
347
|
+
res.status(409).send({
|
|
348
|
+
error: `Each account is limited to ${MAX_PASSKEYS_PER_USER} passkeys.`,
|
|
349
|
+
});
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const created = await db.createPasskey(
|
|
354
|
+
userID,
|
|
355
|
+
pending.name,
|
|
356
|
+
credential.id,
|
|
357
|
+
XUtils.encodeHex(credential.publicKey),
|
|
358
|
+
// The COSE alg is reported back in `info.fmt`-adjacent
|
|
359
|
+
// helpers, but `WebAuthnCredential` only carries the
|
|
360
|
+
// public key; verifyAuthenticationResponse re-derives
|
|
361
|
+
// the alg from the COSE_Key bytes. We persist a
|
|
362
|
+
// best-effort algorithm hint as 0 when unavailable and
|
|
363
|
+
// refuse no algorithms here — verification time is
|
|
364
|
+
// where the real check happens.
|
|
365
|
+
0,
|
|
366
|
+
transports,
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
res.send(msgpack.encode(created));
|
|
370
|
+
},
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
router.get("/user/:id/passkeys", protect, async (req, res) => {
|
|
374
|
+
const userDetails = getUser(req);
|
|
375
|
+
const userID = getParam(req, "id");
|
|
376
|
+
if (userDetails.userID !== userID) {
|
|
377
|
+
res.sendStatus(401);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
const list: Passkey[] = await db.retrievePasskeysByUser(userID);
|
|
381
|
+
res.send(msgpack.encode(list));
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
router.delete(
|
|
385
|
+
"/user/:id/passkeys/:passkeyID",
|
|
386
|
+
protect,
|
|
387
|
+
async (req, res) => {
|
|
388
|
+
const userDetails = getUser(req);
|
|
389
|
+
const userID = getParam(req, "id");
|
|
390
|
+
const passkeyID = getParam(req, "passkeyID");
|
|
391
|
+
if (userDetails.userID !== userID) {
|
|
392
|
+
res.sendStatus(401);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
const row = await db.retrievePasskeyInternal(passkeyID);
|
|
396
|
+
if (!row || row.userID !== userID) {
|
|
397
|
+
res.sendStatus(404);
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
await db.deletePasskey(passkeyID);
|
|
401
|
+
res.sendStatus(200);
|
|
402
|
+
},
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
// ── Public passkey login ───────────────────────────────────────────
|
|
406
|
+
|
|
407
|
+
router.post("/auth/passkey/begin", authLimiter, async (req, res) => {
|
|
408
|
+
const parsed = PasskeyAuthStartPayloadSchema.safeParse(req.body);
|
|
409
|
+
if (!parsed.success) {
|
|
410
|
+
res.status(400).json({
|
|
411
|
+
error: "Invalid begin payload",
|
|
412
|
+
issues: parsed.error.issues,
|
|
413
|
+
});
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const user = await db.retrieveUser(parsed.data.username);
|
|
418
|
+
if (!user) {
|
|
419
|
+
// Don't reveal whether the username exists — return a
|
|
420
|
+
// generic 401 here. (Some flows return a generated stub
|
|
421
|
+
// challenge for username-less / discoverable creds; not
|
|
422
|
+
// implemented yet.)
|
|
423
|
+
res.sendStatus(401);
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const passkeys = await db.retrievePasskeysByUser(user.userID);
|
|
428
|
+
if (passkeys.length === 0) {
|
|
429
|
+
res.sendStatus(401);
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const allowCredentials = await Promise.all(
|
|
434
|
+
passkeys.map(async (pk) => {
|
|
435
|
+
const internal = await db.retrievePasskeyInternal(pk.passkeyID);
|
|
436
|
+
return {
|
|
437
|
+
id: internal?.credentialID ?? "",
|
|
438
|
+
transports: pk.transports.filter(isKnownTransport),
|
|
439
|
+
type: "public-key" as const,
|
|
440
|
+
};
|
|
441
|
+
}),
|
|
442
|
+
);
|
|
443
|
+
|
|
444
|
+
const { rpID } = getRpConfig();
|
|
445
|
+
|
|
446
|
+
const options = await generateAuthenticationOptions({
|
|
447
|
+
allowCredentials: allowCredentials.filter((c) => c.id.length > 0),
|
|
448
|
+
rpID,
|
|
449
|
+
userVerification: "preferred",
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
pruneAuthentications();
|
|
453
|
+
const requestID = crypto.randomUUID();
|
|
454
|
+
pendingAuthentications.set(requestID, {
|
|
455
|
+
challenge: options.challenge,
|
|
456
|
+
createdAt: Date.now(),
|
|
457
|
+
userID: user.userID,
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
res.send(
|
|
461
|
+
msgpack.encode({
|
|
462
|
+
options,
|
|
463
|
+
requestID,
|
|
464
|
+
}),
|
|
465
|
+
);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
router.post("/auth/passkey/finish", authLimiter, async (req, res) => {
|
|
469
|
+
const parsed = PasskeyAuthFinishPayloadSchema.safeParse(req.body);
|
|
470
|
+
if (!parsed.success) {
|
|
471
|
+
res.status(400).json({
|
|
472
|
+
error: "Invalid finish payload",
|
|
473
|
+
issues: parsed.error.issues,
|
|
474
|
+
});
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
pruneAuthentications();
|
|
479
|
+
const pending = pendingAuthentications.get(parsed.data.requestID);
|
|
480
|
+
if (!pending) {
|
|
481
|
+
res.status(401).send({
|
|
482
|
+
error: "Authentication challenge not found or expired.",
|
|
483
|
+
});
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
// Single-use.
|
|
487
|
+
pendingAuthentications.delete(parsed.data.requestID);
|
|
488
|
+
|
|
489
|
+
// The browser's AuthenticationResponseJSON is opaque to spire
|
|
490
|
+
// — simplewebauthn does the structural decode + signature
|
|
491
|
+
// verification. The cast from `Record<string, unknown>` to
|
|
492
|
+
// the branded type is the trust boundary.
|
|
493
|
+
const rawAssertion = parsed.data.response as unknown;
|
|
494
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- structurally validated by simplewebauthn below
|
|
495
|
+
const assertion = rawAssertion as AuthenticationResponseJSON;
|
|
496
|
+
const credentialID =
|
|
497
|
+
typeof assertion.id === "string" ? assertion.id : "";
|
|
498
|
+
if (!credentialID) {
|
|
499
|
+
res.status(400).send({
|
|
500
|
+
error: "Assertion is missing a credential id.",
|
|
501
|
+
});
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const passkeyRow = await db.retrievePasskeyByCredentialID(credentialID);
|
|
506
|
+
if (!passkeyRow || passkeyRow.userID !== pending.userID) {
|
|
507
|
+
res.status(401).send({
|
|
508
|
+
error: "No matching passkey for this account.",
|
|
509
|
+
});
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const { expectedOrigin, rpID } = getRpConfig();
|
|
514
|
+
|
|
515
|
+
// Force `Uint8Array<ArrayBuffer>` (not `ArrayBufferLike`) so
|
|
516
|
+
// simplewebauthn's strict generic accepts the buffer; the
|
|
517
|
+
// raw decoded bytes are identical.
|
|
518
|
+
const credentialPublicKey = new Uint8Array(
|
|
519
|
+
XUtils.decodeHex(passkeyRow.publicKey),
|
|
520
|
+
);
|
|
521
|
+
|
|
522
|
+
let verification;
|
|
523
|
+
try {
|
|
524
|
+
verification = await verifyAuthenticationResponse({
|
|
525
|
+
credential: {
|
|
526
|
+
counter: passkeyRow.signCount,
|
|
527
|
+
id: passkeyRow.credentialID,
|
|
528
|
+
publicKey: credentialPublicKey,
|
|
529
|
+
transports: passkeyRow.transports
|
|
530
|
+
.split(",")
|
|
531
|
+
.filter(isKnownTransport),
|
|
532
|
+
},
|
|
533
|
+
expectedChallenge: pending.challenge,
|
|
534
|
+
expectedOrigin,
|
|
535
|
+
expectedRPID: rpID,
|
|
536
|
+
requireUserVerification: false,
|
|
537
|
+
response: assertion,
|
|
538
|
+
});
|
|
539
|
+
} catch (err: unknown) {
|
|
540
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
541
|
+
res.status(401).send({
|
|
542
|
+
error: "Passkey assertion invalid: " + message,
|
|
543
|
+
});
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
if (!verification.verified) {
|
|
548
|
+
res.status(401).send({ error: "Passkey assertion failed." });
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// Counter should be strictly increasing per the WebAuthn spec.
|
|
553
|
+
// FIDO authenticators that report 0 are excused (the spec says
|
|
554
|
+
// 0→0 is legitimate when the authenticator has no counter).
|
|
555
|
+
const newCounter = verification.authenticationInfo.newCounter;
|
|
556
|
+
if (
|
|
557
|
+
newCounter !== 0 &&
|
|
558
|
+
passkeyRow.signCount !== 0 &&
|
|
559
|
+
newCounter <= passkeyRow.signCount
|
|
560
|
+
) {
|
|
561
|
+
res.status(401).send({
|
|
562
|
+
error: "Authenticator counter regressed (possible cloned credential).",
|
|
563
|
+
});
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
await db.markPasskeyUsed(passkeyRow.passkeyID, newCounter);
|
|
568
|
+
|
|
569
|
+
const user = await db.retrieveUser(pending.userID);
|
|
570
|
+
if (!user) {
|
|
571
|
+
res.status(404).send({ error: "Account not found." });
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
const censored = censorUser(user);
|
|
575
|
+
const token = signPasskeyToken({
|
|
576
|
+
passkeyID: passkeyRow.passkeyID,
|
|
577
|
+
user: censored,
|
|
578
|
+
});
|
|
579
|
+
res.send(
|
|
580
|
+
msgpack.encode({
|
|
581
|
+
passkeyID: passkeyRow.passkeyID,
|
|
582
|
+
token,
|
|
583
|
+
user: censored,
|
|
584
|
+
}),
|
|
585
|
+
);
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
return router;
|
|
589
|
+
};
|