@vex-chat/spire 4.0.0 → 4.1.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 +8 -0
- package/dist/Database.js +68 -0
- package/dist/Database.js.map +1 -1
- package/dist/Spire.js +1 -1
- package/dist/Spire.js.map +1 -1
- package/dist/server/avatar.js +3 -2
- package/dist/server/avatar.js.map +1 -1
- package/dist/server/cliPasskeyPage.js +109 -11
- package/dist/server/cliPasskeyPage.js.map +1 -1
- package/dist/server/index.js +163 -16
- package/dist/server/index.js.map +1 -1
- package/dist/server/passkey.js +326 -3
- package/dist/server/passkey.js.map +1 -1
- package/dist/server/serverIcon.d.ts +10 -0
- package/dist/server/serverIcon.js +158 -0
- package/dist/server/serverIcon.js.map +1 -0
- package/package.json +2 -2
- package/src/Database.ts +83 -0
- package/src/Spire.ts +1 -1
- package/src/__tests__/browserPasskeyAuthentication.spec.ts +192 -0
- package/src/__tests__/browserPasskeyRegistration.spec.ts +182 -0
- package/src/__tests__/cliPasskeyPage.spec.ts +6 -0
- package/src/__tests__/serverManagement.spec.ts +262 -0
- package/src/server/avatar.ts +3 -2
- package/src/server/cliPasskeyPage.ts +109 -11
- package/src/server/index.ts +211 -31
- package/src/server/passkey.ts +480 -3
- package/src/server/serverIcon.ts +199 -0
package/src/server/passkey.ts
CHANGED
|
@@ -12,6 +12,8 @@ import type {
|
|
|
12
12
|
} from "@simplewebauthn/server";
|
|
13
13
|
import type { Passkey } from "@vex-chat/types";
|
|
14
14
|
|
|
15
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
16
|
+
|
|
15
17
|
import express from "express";
|
|
16
18
|
|
|
17
19
|
import { XUtils } from "@vex-chat/crypto";
|
|
@@ -28,6 +30,7 @@ import {
|
|
|
28
30
|
verifyAuthenticationResponse,
|
|
29
31
|
verifyRegistrationResponse,
|
|
30
32
|
} from "@simplewebauthn/server";
|
|
33
|
+
import { z } from "zod";
|
|
31
34
|
|
|
32
35
|
import { JWT_EXPIRY_PASSKEY } from "../Spire.ts";
|
|
33
36
|
import { signAuthJwt } from "../utils/authJwt.ts";
|
|
@@ -42,17 +45,50 @@ import { protect, protectAnyAuth } from "./index.ts";
|
|
|
42
45
|
|
|
43
46
|
const REGISTRATION_TTL_MS = 5 * 60 * 1000; // 5 min
|
|
44
47
|
const AUTHENTICATION_TTL_MS = 5 * 60 * 1000;
|
|
48
|
+
const BROWSER_AUTHENTICATION_TTL_MS = 5 * 60 * 1000;
|
|
49
|
+
const BROWSER_REGISTRATION_TTL_MS = 5 * 60 * 1000;
|
|
45
50
|
// Cap each user's passkey count so a compromised JWT can't fill the
|
|
46
51
|
// table. WebAuthn-style apps typically allow ~20; we go conservative.
|
|
47
52
|
const MAX_PASSKEYS_PER_USER = 10;
|
|
48
53
|
|
|
54
|
+
type AuthenticationOptions = Awaited<
|
|
55
|
+
ReturnType<typeof generateAuthenticationOptions>
|
|
56
|
+
>;
|
|
57
|
+
|
|
58
|
+
type BrowserAuthenticationStatus = "in_progress" | "pending" | "response_ready";
|
|
59
|
+
type BrowserRegistrationStatus = "failed" | "in_progress" | "pending";
|
|
60
|
+
|
|
49
61
|
interface PendingAuthentication {
|
|
62
|
+
browserRequestID: string;
|
|
50
63
|
challenge: string;
|
|
51
64
|
createdAt: number;
|
|
65
|
+
options: AuthenticationOptions;
|
|
66
|
+
userID: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface PendingBrowserAuthentication {
|
|
70
|
+
authenticationRequestID: string;
|
|
71
|
+
createdAt: number;
|
|
72
|
+
response?: Record<string, unknown>;
|
|
73
|
+
status: BrowserAuthenticationStatus;
|
|
74
|
+
tokenDigest: Buffer;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface PendingBrowserRegistration {
|
|
78
|
+
challenge?: string;
|
|
79
|
+
createdAt: number;
|
|
80
|
+
deviceID: string;
|
|
81
|
+
error?: string;
|
|
82
|
+
name: string;
|
|
83
|
+
registrationRequestID: string;
|
|
84
|
+
status: BrowserRegistrationStatus;
|
|
85
|
+
tokenDigest: Buffer;
|
|
52
86
|
userID: string;
|
|
87
|
+
username: string;
|
|
53
88
|
}
|
|
54
89
|
|
|
55
90
|
interface PendingRegistration {
|
|
91
|
+
browserRequestID: string;
|
|
56
92
|
challenge: string;
|
|
57
93
|
createdAt: number;
|
|
58
94
|
deviceID: string;
|
|
@@ -62,6 +98,102 @@ interface PendingRegistration {
|
|
|
62
98
|
|
|
63
99
|
const pendingRegistrations = new Map<string, PendingRegistration>();
|
|
64
100
|
const pendingAuthentications = new Map<string, PendingAuthentication>();
|
|
101
|
+
const pendingBrowserAuthentications = new Map<
|
|
102
|
+
string,
|
|
103
|
+
PendingBrowserAuthentication
|
|
104
|
+
>();
|
|
105
|
+
const pendingBrowserRegistrations = new Map<
|
|
106
|
+
string,
|
|
107
|
+
PendingBrowserRegistration
|
|
108
|
+
>();
|
|
109
|
+
|
|
110
|
+
const BrowserHandoffTokenSchema = z.object({
|
|
111
|
+
token: z.string().min(32).max(256),
|
|
112
|
+
});
|
|
113
|
+
const BrowserAuthenticationFinishSchema = BrowserHandoffTokenSchema.extend({
|
|
114
|
+
response: z.record(z.string(), z.unknown()),
|
|
115
|
+
});
|
|
116
|
+
const BrowserRegistrationFinishSchema = BrowserHandoffTokenSchema.extend({
|
|
117
|
+
response: z.record(z.string(), z.unknown()),
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
function browserTokenDigest(token: string): Buffer {
|
|
121
|
+
return createHash("sha256").update(token, "utf8").digest();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function browserTokenMatches(
|
|
125
|
+
pending: { tokenDigest: Buffer },
|
|
126
|
+
token: string,
|
|
127
|
+
): boolean {
|
|
128
|
+
return timingSafeEqual(pending.tokenDigest, browserTokenDigest(token));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function createBrowserAuthentication(authenticationRequestID: string): {
|
|
132
|
+
browserToken: string;
|
|
133
|
+
expiresAt: string;
|
|
134
|
+
requestID: string;
|
|
135
|
+
} {
|
|
136
|
+
pruneBrowserAuthentications();
|
|
137
|
+
const requestID = crypto.randomUUID();
|
|
138
|
+
const browserToken = randomBytes(32).toString("base64url");
|
|
139
|
+
const createdAt = Date.now();
|
|
140
|
+
pendingBrowserAuthentications.set(requestID, {
|
|
141
|
+
authenticationRequestID,
|
|
142
|
+
createdAt,
|
|
143
|
+
status: "pending",
|
|
144
|
+
tokenDigest: browserTokenDigest(browserToken),
|
|
145
|
+
});
|
|
146
|
+
return {
|
|
147
|
+
browserToken,
|
|
148
|
+
expiresAt: new Date(
|
|
149
|
+
createdAt + BROWSER_AUTHENTICATION_TTL_MS,
|
|
150
|
+
).toISOString(),
|
|
151
|
+
requestID,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function createBrowserRegistration(args: {
|
|
156
|
+
deviceID: string;
|
|
157
|
+
name: string;
|
|
158
|
+
registrationRequestID: string;
|
|
159
|
+
userID: string;
|
|
160
|
+
username: string;
|
|
161
|
+
}): {
|
|
162
|
+
browserToken: string;
|
|
163
|
+
expiresAt: string;
|
|
164
|
+
requestID: string;
|
|
165
|
+
} {
|
|
166
|
+
pruneBrowserRegistrations();
|
|
167
|
+
const requestID = crypto.randomUUID();
|
|
168
|
+
const browserToken = randomBytes(32).toString("base64url");
|
|
169
|
+
const createdAt = Date.now();
|
|
170
|
+
pendingBrowserRegistrations.set(requestID, {
|
|
171
|
+
createdAt,
|
|
172
|
+
deviceID: args.deviceID,
|
|
173
|
+
name: args.name,
|
|
174
|
+
registrationRequestID: args.registrationRequestID,
|
|
175
|
+
status: "pending",
|
|
176
|
+
tokenDigest: browserTokenDigest(browserToken),
|
|
177
|
+
userID: args.userID,
|
|
178
|
+
username: args.username,
|
|
179
|
+
});
|
|
180
|
+
return {
|
|
181
|
+
browserToken,
|
|
182
|
+
expiresAt: new Date(
|
|
183
|
+
createdAt + BROWSER_REGISTRATION_TTL_MS,
|
|
184
|
+
).toISOString(),
|
|
185
|
+
requestID,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function failBrowserRegistration(
|
|
190
|
+
pending: PendingBrowserRegistration,
|
|
191
|
+
error: string,
|
|
192
|
+
): void {
|
|
193
|
+
delete pending.challenge;
|
|
194
|
+
pending.error = error;
|
|
195
|
+
pending.status = "failed";
|
|
196
|
+
}
|
|
65
197
|
|
|
66
198
|
/**
|
|
67
199
|
* Returns the WebAuthn relying-party config from the environment.
|
|
@@ -71,7 +203,7 @@ const pendingAuthentications = new Map<string, PendingAuthentication>();
|
|
|
71
203
|
* - `SPIRE_PASSKEY_RP_NAME` — display name for prompts. Defaults to
|
|
72
204
|
* "Vex".
|
|
73
205
|
* - `SPIRE_PASSKEY_ORIGINS` — comma-separated allowlist of expected
|
|
74
|
-
* client origins (e.g. `https://app.vex.wtf,
|
|
206
|
+
* client origins (e.g. `https://app.vex.wtf,
|
|
75
207
|
* http://localhost:5173`). Required: WebAuthn binds an assertion
|
|
76
208
|
* to its origin and we must check it explicitly.
|
|
77
209
|
*
|
|
@@ -123,6 +255,23 @@ function pruneAuthentications(nowMs = Date.now()): void {
|
|
|
123
255
|
for (const [id, entry] of pendingAuthentications.entries()) {
|
|
124
256
|
if (nowMs - entry.createdAt > AUTHENTICATION_TTL_MS) {
|
|
125
257
|
pendingAuthentications.delete(id);
|
|
258
|
+
pendingBrowserAuthentications.delete(entry.browserRequestID);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function pruneBrowserAuthentications(nowMs = Date.now()): void {
|
|
264
|
+
for (const [id, entry] of pendingBrowserAuthentications.entries()) {
|
|
265
|
+
if (nowMs - entry.createdAt > BROWSER_AUTHENTICATION_TTL_MS) {
|
|
266
|
+
pendingBrowserAuthentications.delete(id);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function pruneBrowserRegistrations(nowMs = Date.now()): void {
|
|
272
|
+
for (const [id, entry] of pendingBrowserRegistrations.entries()) {
|
|
273
|
+
if (nowMs - entry.createdAt > BROWSER_REGISTRATION_TTL_MS) {
|
|
274
|
+
pendingBrowserRegistrations.delete(id);
|
|
126
275
|
}
|
|
127
276
|
}
|
|
128
277
|
}
|
|
@@ -240,7 +389,15 @@ export const getPasskeyRouter = (db: Database) => {
|
|
|
240
389
|
|
|
241
390
|
pruneRegistrations();
|
|
242
391
|
const requestID = crypto.randomUUID();
|
|
392
|
+
const browserHandoff = createBrowserRegistration({
|
|
393
|
+
deviceID: req.device.deviceID,
|
|
394
|
+
name: parsed.data.name,
|
|
395
|
+
registrationRequestID: requestID,
|
|
396
|
+
userID,
|
|
397
|
+
username: userDetails.username,
|
|
398
|
+
});
|
|
243
399
|
pendingRegistrations.set(requestID, {
|
|
400
|
+
browserRequestID: browserHandoff.requestID,
|
|
244
401
|
challenge: options.challenge,
|
|
245
402
|
createdAt: Date.now(),
|
|
246
403
|
deviceID: req.device.deviceID,
|
|
@@ -254,7 +411,11 @@ export const getPasskeyRouter = (db: Database) => {
|
|
|
254
411
|
// SimpleWebAuthn). The wire shape is identical — both
|
|
255
412
|
// sides hand the JSON straight to navigator.credentials.
|
|
256
413
|
sendWireResponse(req, res, {
|
|
257
|
-
options
|
|
414
|
+
// Older libvex releases preserve the opaque options object but
|
|
415
|
+
// strip unknown top-level fields. Keep the handoff inside that
|
|
416
|
+
// object so desktop can adopt the HTTPS bridge before its next
|
|
417
|
+
// package bump; WebAuthn ignores unknown dictionary members.
|
|
418
|
+
options: { ...options, vexBrowserHandoff: browserHandoff },
|
|
258
419
|
requestID,
|
|
259
420
|
});
|
|
260
421
|
},
|
|
@@ -304,6 +465,7 @@ export const getPasskeyRouter = (db: Database) => {
|
|
|
304
465
|
// Single-use challenge: clear immediately so a replay can't
|
|
305
466
|
// re-bind the credential to a second name.
|
|
306
467
|
pendingRegistrations.delete(parsed.data.requestID);
|
|
468
|
+
pendingBrowserRegistrations.delete(pending.browserRequestID);
|
|
307
469
|
|
|
308
470
|
const { expectedOrigin, rpID } = getRpConfig();
|
|
309
471
|
|
|
@@ -419,6 +581,317 @@ export const getPasskeyRouter = (db: Database) => {
|
|
|
419
581
|
|
|
420
582
|
// ── Public passkey login ───────────────────────────────────────────
|
|
421
583
|
|
|
584
|
+
router.post(
|
|
585
|
+
"/auth/passkey/browser-registration/:requestID/begin",
|
|
586
|
+
authLimiter,
|
|
587
|
+
async (req, res) => {
|
|
588
|
+
const parsed = BrowserHandoffTokenSchema.safeParse(req.body);
|
|
589
|
+
if (!parsed.success) {
|
|
590
|
+
res.status(400).send({ error: "Invalid browser handoff." });
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
pruneBrowserRegistrations();
|
|
594
|
+
const pending = pendingBrowserRegistrations.get(
|
|
595
|
+
getParam(req, "requestID"),
|
|
596
|
+
);
|
|
597
|
+
if (!pending || !browserTokenMatches(pending, parsed.data.token)) {
|
|
598
|
+
res.status(401).send({
|
|
599
|
+
error: "Browser handoff is invalid or expired.",
|
|
600
|
+
});
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
if (pending.status === "failed") {
|
|
604
|
+
res.status(409).send({
|
|
605
|
+
error:
|
|
606
|
+
pending.error ??
|
|
607
|
+
"Browser handoff has already completed.",
|
|
608
|
+
});
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
const device = await db.retrieveDevice(pending.deviceID);
|
|
612
|
+
if (!device || device.owner !== pending.userID) {
|
|
613
|
+
failBrowserRegistration(
|
|
614
|
+
pending,
|
|
615
|
+
"The originating device is no longer approved.",
|
|
616
|
+
);
|
|
617
|
+
res.status(401).send({ error: pending.error });
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
const existing = await db.retrievePasskeysByUser(pending.userID);
|
|
621
|
+
if (existing.length >= MAX_PASSKEYS_PER_USER) {
|
|
622
|
+
failBrowserRegistration(
|
|
623
|
+
pending,
|
|
624
|
+
`Each account is limited to ${MAX_PASSKEYS_PER_USER} passkeys.`,
|
|
625
|
+
);
|
|
626
|
+
res.status(409).send({ error: pending.error });
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
// The browser and native challenges represent one user action.
|
|
630
|
+
// Once the HTTPS path starts, the custom-origin path cannot also
|
|
631
|
+
// finish and create a second credential.
|
|
632
|
+
pendingRegistrations.delete(pending.registrationRequestID);
|
|
633
|
+
|
|
634
|
+
const { rpID, rpName } = getRpConfig();
|
|
635
|
+
const options = await generateRegistrationOptions({
|
|
636
|
+
attestationType: "none",
|
|
637
|
+
authenticatorSelection: {
|
|
638
|
+
requireResidentKey: false,
|
|
639
|
+
residentKey: "preferred",
|
|
640
|
+
userVerification: "required",
|
|
641
|
+
},
|
|
642
|
+
excludeCredentials: [],
|
|
643
|
+
rpID,
|
|
644
|
+
rpName,
|
|
645
|
+
userDisplayName: pending.username,
|
|
646
|
+
userID: new TextEncoder().encode(pending.userID),
|
|
647
|
+
userName: pending.username,
|
|
648
|
+
});
|
|
649
|
+
pending.challenge = options.challenge;
|
|
650
|
+
delete pending.error;
|
|
651
|
+
pending.status = "in_progress";
|
|
652
|
+
sendWireResponse(req, res, { options });
|
|
653
|
+
},
|
|
654
|
+
);
|
|
655
|
+
|
|
656
|
+
router.post(
|
|
657
|
+
"/auth/passkey/browser-registration/:requestID/finish",
|
|
658
|
+
authLimiter,
|
|
659
|
+
async (req, res) => {
|
|
660
|
+
const parsed = BrowserRegistrationFinishSchema.safeParse(req.body);
|
|
661
|
+
if (!parsed.success) {
|
|
662
|
+
res.status(400).send({
|
|
663
|
+
error: "Invalid browser handoff response.",
|
|
664
|
+
});
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
pruneBrowserRegistrations();
|
|
668
|
+
const pending = pendingBrowserRegistrations.get(
|
|
669
|
+
getParam(req, "requestID"),
|
|
670
|
+
);
|
|
671
|
+
if (!pending || !browserTokenMatches(pending, parsed.data.token)) {
|
|
672
|
+
res.status(401).send({
|
|
673
|
+
error: "Browser handoff is invalid or expired.",
|
|
674
|
+
});
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
if (pending.status !== "in_progress" || !pending.challenge) {
|
|
678
|
+
res.status(409).send({
|
|
679
|
+
error: "Request a fresh passkey challenge and try again.",
|
|
680
|
+
});
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
const challenge = pending.challenge;
|
|
684
|
+
delete pending.challenge;
|
|
685
|
+
|
|
686
|
+
const device = await db.retrieveDevice(pending.deviceID);
|
|
687
|
+
if (!device || device.owner !== pending.userID) {
|
|
688
|
+
failBrowserRegistration(
|
|
689
|
+
pending,
|
|
690
|
+
"The originating device is no longer approved.",
|
|
691
|
+
);
|
|
692
|
+
res.status(401).send({ error: pending.error });
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
const { expectedOrigin, rpID } = getRpConfig();
|
|
697
|
+
let verification;
|
|
698
|
+
try {
|
|
699
|
+
verification = await verifyRegistrationResponse({
|
|
700
|
+
expectedChallenge: challenge,
|
|
701
|
+
expectedOrigin,
|
|
702
|
+
expectedRPID: rpID,
|
|
703
|
+
requireUserVerification: true,
|
|
704
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- structurally validated by simplewebauthn below
|
|
705
|
+
response: parsed.data
|
|
706
|
+
.response as unknown as RegistrationResponseJSON,
|
|
707
|
+
});
|
|
708
|
+
} catch (err: unknown) {
|
|
709
|
+
logWebAuthnFailure("browser registration", err);
|
|
710
|
+
failBrowserRegistration(
|
|
711
|
+
pending,
|
|
712
|
+
"Passkey attestation could not be verified.",
|
|
713
|
+
);
|
|
714
|
+
res.status(400).send({ error: pending.error });
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
if (!verification.verified) {
|
|
718
|
+
failBrowserRegistration(pending, "Passkey attestation failed.");
|
|
719
|
+
res.status(400).send({ error: pending.error });
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const credential = verification.registrationInfo.credential;
|
|
724
|
+
const duplicate = await db.retrievePasskeyByCredentialID(
|
|
725
|
+
credential.id,
|
|
726
|
+
);
|
|
727
|
+
if (duplicate) {
|
|
728
|
+
failBrowserRegistration(
|
|
729
|
+
pending,
|
|
730
|
+
"This authenticator is already registered.",
|
|
731
|
+
);
|
|
732
|
+
res.status(409).send({ error: pending.error });
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
const existing = await db.retrievePasskeysByUser(pending.userID);
|
|
736
|
+
if (existing.length >= MAX_PASSKEYS_PER_USER) {
|
|
737
|
+
failBrowserRegistration(
|
|
738
|
+
pending,
|
|
739
|
+
`Each account is limited to ${MAX_PASSKEYS_PER_USER} passkeys.`,
|
|
740
|
+
);
|
|
741
|
+
res.status(409).send({ error: pending.error });
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
const created = await db.createPasskey(
|
|
746
|
+
pending.userID,
|
|
747
|
+
pending.name,
|
|
748
|
+
credential.id,
|
|
749
|
+
XUtils.encodeHex(credential.publicKey),
|
|
750
|
+
0,
|
|
751
|
+
sanitizeTransports(credential.transports ?? []),
|
|
752
|
+
);
|
|
753
|
+
pendingBrowserRegistrations.delete(getParam(req, "requestID"));
|
|
754
|
+
sendWireResponse(req, res, created);
|
|
755
|
+
},
|
|
756
|
+
);
|
|
757
|
+
|
|
758
|
+
router.post(
|
|
759
|
+
"/auth/passkey/browser-authentication/:requestID/begin",
|
|
760
|
+
authLimiter,
|
|
761
|
+
(req, res) => {
|
|
762
|
+
const parsed = BrowserHandoffTokenSchema.safeParse(req.body);
|
|
763
|
+
if (!parsed.success) {
|
|
764
|
+
res.status(400).send({ error: "Invalid browser handoff." });
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
pruneAuthentications();
|
|
768
|
+
pruneBrowserAuthentications();
|
|
769
|
+
const browserRequestID = getParam(req, "requestID");
|
|
770
|
+
const browserPending =
|
|
771
|
+
pendingBrowserAuthentications.get(browserRequestID);
|
|
772
|
+
if (
|
|
773
|
+
!browserPending ||
|
|
774
|
+
!browserTokenMatches(browserPending, parsed.data.token)
|
|
775
|
+
) {
|
|
776
|
+
res.status(401).send({
|
|
777
|
+
error: "Browser handoff is invalid or expired.",
|
|
778
|
+
});
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
const authenticationPending = pendingAuthentications.get(
|
|
782
|
+
browserPending.authenticationRequestID,
|
|
783
|
+
);
|
|
784
|
+
if (!authenticationPending) {
|
|
785
|
+
pendingBrowserAuthentications.delete(browserRequestID);
|
|
786
|
+
res.status(401).send({
|
|
787
|
+
error: "Authentication request is invalid or expired.",
|
|
788
|
+
});
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
if (browserPending.status === "response_ready") {
|
|
792
|
+
res.status(409).send({
|
|
793
|
+
error: "This browser handoff has already completed.",
|
|
794
|
+
});
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
browserPending.status = "in_progress";
|
|
798
|
+
sendWireResponse(req, res, {
|
|
799
|
+
options: authenticationPending.options,
|
|
800
|
+
});
|
|
801
|
+
},
|
|
802
|
+
);
|
|
803
|
+
|
|
804
|
+
router.post(
|
|
805
|
+
"/auth/passkey/browser-authentication/:requestID/finish",
|
|
806
|
+
authLimiter,
|
|
807
|
+
(req, res) => {
|
|
808
|
+
const parsed = BrowserAuthenticationFinishSchema.safeParse(
|
|
809
|
+
req.body,
|
|
810
|
+
);
|
|
811
|
+
if (!parsed.success) {
|
|
812
|
+
res.status(400).send({
|
|
813
|
+
error: "Invalid browser handoff response.",
|
|
814
|
+
});
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
pruneAuthentications();
|
|
818
|
+
pruneBrowserAuthentications();
|
|
819
|
+
const browserRequestID = getParam(req, "requestID");
|
|
820
|
+
const browserPending =
|
|
821
|
+
pendingBrowserAuthentications.get(browserRequestID);
|
|
822
|
+
if (
|
|
823
|
+
!browserPending ||
|
|
824
|
+
!browserTokenMatches(browserPending, parsed.data.token)
|
|
825
|
+
) {
|
|
826
|
+
res.status(401).send({
|
|
827
|
+
error: "Browser handoff is invalid or expired.",
|
|
828
|
+
});
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
if (
|
|
832
|
+
browserPending.status !== "in_progress" ||
|
|
833
|
+
!pendingAuthentications.has(
|
|
834
|
+
browserPending.authenticationRequestID,
|
|
835
|
+
)
|
|
836
|
+
) {
|
|
837
|
+
res.status(409).send({
|
|
838
|
+
error: "Request a fresh passkey challenge and try again.",
|
|
839
|
+
});
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
browserPending.response = parsed.data.response;
|
|
843
|
+
browserPending.status = "response_ready";
|
|
844
|
+
sendWireResponse(req, res, { ok: true });
|
|
845
|
+
},
|
|
846
|
+
);
|
|
847
|
+
|
|
848
|
+
router.post(
|
|
849
|
+
"/auth/passkey/browser-authentication/:requestID/status",
|
|
850
|
+
authLimiter,
|
|
851
|
+
(req, res) => {
|
|
852
|
+
const parsed = BrowserHandoffTokenSchema.safeParse(req.body);
|
|
853
|
+
if (!parsed.success) {
|
|
854
|
+
res.status(400).send({ error: "Invalid browser handoff." });
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
pruneAuthentications();
|
|
858
|
+
pruneBrowserAuthentications();
|
|
859
|
+
const browserRequestID = getParam(req, "requestID");
|
|
860
|
+
const browserPending =
|
|
861
|
+
pendingBrowserAuthentications.get(browserRequestID);
|
|
862
|
+
if (
|
|
863
|
+
!browserPending ||
|
|
864
|
+
!browserTokenMatches(browserPending, parsed.data.token)
|
|
865
|
+
) {
|
|
866
|
+
res.status(401).send({
|
|
867
|
+
error: "Browser handoff is invalid or expired.",
|
|
868
|
+
});
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
if (
|
|
872
|
+
!pendingAuthentications.has(
|
|
873
|
+
browserPending.authenticationRequestID,
|
|
874
|
+
)
|
|
875
|
+
) {
|
|
876
|
+
pendingBrowserAuthentications.delete(browserRequestID);
|
|
877
|
+
res.status(401).send({
|
|
878
|
+
error: "Authentication request is invalid or expired.",
|
|
879
|
+
});
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
if (
|
|
883
|
+
browserPending.status !== "response_ready" ||
|
|
884
|
+
!browserPending.response
|
|
885
|
+
) {
|
|
886
|
+
res.status(202).send({ status: browserPending.status });
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
sendWireResponse(req, res, {
|
|
890
|
+
response: browserPending.response,
|
|
891
|
+
});
|
|
892
|
+
},
|
|
893
|
+
);
|
|
894
|
+
|
|
422
895
|
router.post("/auth/passkey/begin", authLimiter, async (req, res) => {
|
|
423
896
|
const parsed = PasskeyAuthStartPayloadSchema.safeParse(req.body);
|
|
424
897
|
if (!parsed.success) {
|
|
@@ -466,14 +939,17 @@ export const getPasskeyRouter = (db: Database) => {
|
|
|
466
939
|
|
|
467
940
|
pruneAuthentications();
|
|
468
941
|
const requestID = crypto.randomUUID();
|
|
942
|
+
const browserHandoff = createBrowserAuthentication(requestID);
|
|
469
943
|
pendingAuthentications.set(requestID, {
|
|
944
|
+
browserRequestID: browserHandoff.requestID,
|
|
470
945
|
challenge: options.challenge,
|
|
471
946
|
createdAt: Date.now(),
|
|
947
|
+
options,
|
|
472
948
|
userID: user.userID,
|
|
473
949
|
});
|
|
474
950
|
|
|
475
951
|
sendWireResponse(req, res, {
|
|
476
|
-
options,
|
|
952
|
+
options: { ...options, vexBrowserHandoff: browserHandoff },
|
|
477
953
|
requestID,
|
|
478
954
|
});
|
|
479
955
|
});
|
|
@@ -498,6 +974,7 @@ export const getPasskeyRouter = (db: Database) => {
|
|
|
498
974
|
}
|
|
499
975
|
// Single-use.
|
|
500
976
|
pendingAuthentications.delete(parsed.data.requestID);
|
|
977
|
+
pendingBrowserAuthentications.delete(pending.browserRequestID);
|
|
501
978
|
|
|
502
979
|
// The browser's AuthenticationResponseJSON is opaque to spire
|
|
503
980
|
// — simplewebauthn does the structural decode + signature
|