@vex-chat/spire 2.3.3 → 2.3.4
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 +1 -1
- package/dist/server/cliPasskeyPage.d.ts +7 -0
- package/dist/server/cliPasskeyPage.js +504 -0
- package/dist/server/cliPasskeyPage.js.map +1 -0
- package/dist/server/index.js +5 -0
- package/dist/server/index.js.map +1 -1
- package/dist/server/passkey.js +9 -9
- package/dist/server/passkey.js.map +1 -1
- package/dist/server/passkeyDevices.js +3 -3
- package/dist/server/passkeyDevices.js.map +1 -1
- package/dist/server/wireResponse.d.ts +12 -0
- package/dist/server/wireResponse.js +19 -0
- package/dist/server/wireResponse.js.map +1 -0
- package/package.json +3 -3
- package/src/__tests__/cliPasskeyPage.spec.ts +53 -0
- package/src/__tests__/wireResponse.spec.ts +46 -0
- package/src/server/cliPasskeyPage.ts +510 -0
- package/src/server/index.ts +7 -0
- package/src/server/passkey.ts +16 -22
- package/src/server/passkeyDevices.ts +3 -4
- package/src/server/wireResponse.ts +26 -0
package/README.md
CHANGED
|
@@ -83,7 +83,7 @@ Spire reads configuration from environment variables. **Docker Compose:** put th
|
|
|
83
83
|
|
|
84
84
|
### Passkeys / WebAuthn
|
|
85
85
|
|
|
86
|
-
The `/auth/passkey/*`, `/user/:id/passkeys/*`, and `/passkey/*` routes are gated on `SPIRE_PASSKEY_RP_ID` and `SPIRE_PASSKEY_ORIGINS`; without them those endpoints return `500 Passkeys are not configured`. See [`.env.example`](./.env.example) for the full set, including the optional `SPIRE_PASSKEY_IOS_APP_IDS`, `SPIRE_PASSKEY_ANDROID_PACKAGE`, and `SPIRE_PASSKEY_ANDROID_FINGERPRINTS` triple that makes spire serve the WebAuthn well-known association files (`/.well-known/apple-app-site-association`, `/.well-known/assetlinks.json`) directly. Those endpoints 404 when their env vars aren't set, so a non-passkey deployment is indistinguishable from one that hasn't enrolled an app.
|
|
86
|
+
The `/auth/passkey/*`, `/user/:id/passkeys/*`, and `/passkey/*` routes are gated on `SPIRE_PASSKEY_RP_ID` and `SPIRE_PASSKEY_ORIGINS`; without them those endpoints return `500 Passkeys are not configured`. Spire also serves the CLI WebAuthn bridge at `/cli/passkey`, which should be loaded from an origin listed in `SPIRE_PASSKEY_ORIGINS` so browser passkey ceremonies verify against the same RP config as native clients. See [`.env.example`](./.env.example) for the full set, including the optional `SPIRE_PASSKEY_IOS_APP_IDS`, `SPIRE_PASSKEY_ANDROID_PACKAGE`, and `SPIRE_PASSKEY_ANDROID_FINGERPRINTS` triple that makes spire serve the WebAuthn well-known association files (`/.well-known/apple-app-site-association`, `/.well-known/assetlinks.json`) directly. Those endpoints 404 when their env vars aren't set, so a non-passkey deployment is indistinguishable from one that hasn't enrolled an app.
|
|
87
87
|
|
|
88
88
|
The same `SPIRE_PASSKEY_ANDROID_FINGERPRINTS` value is reused by `getRpConfig()` to derive `android:apk-key-hash:<base64url>` entries, which it merges into the WebAuthn `expectedOrigin` allowlist. Native Android Credential Manager sets `clientDataJSON.origin` to that string instead of the RP host, so without those entries simplewebauthn rejects every native-Android assertion at the origin check (the mobile UI surfaces this as a generic "RP failed" error). Operators only have to publish the cert fingerprints; the base64url math is handled server-side.
|
|
89
89
|
|
|
@@ -0,0 +1,504 @@
|
|
|
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
|
+
import express from "express";
|
|
7
|
+
const CSP = [
|
|
8
|
+
"default-src 'none'",
|
|
9
|
+
"base-uri 'none'",
|
|
10
|
+
"connect-src 'self' http://localhost:* http://127.0.0.1:*",
|
|
11
|
+
"form-action 'none'",
|
|
12
|
+
"frame-ancestors 'none'",
|
|
13
|
+
"img-src 'none'",
|
|
14
|
+
"script-src 'unsafe-inline'",
|
|
15
|
+
"style-src 'unsafe-inline'",
|
|
16
|
+
].join("; ");
|
|
17
|
+
const CLI_PASSKEY_PAGE = `<!doctype html>
|
|
18
|
+
<html lang="en">
|
|
19
|
+
<head>
|
|
20
|
+
<meta charset="utf-8" />
|
|
21
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
22
|
+
<title>Vex Passkey</title>
|
|
23
|
+
<style>
|
|
24
|
+
:root {
|
|
25
|
+
color-scheme: dark;
|
|
26
|
+
--bg: #080b0d;
|
|
27
|
+
--panel: #10161a;
|
|
28
|
+
--panel-border: #27323a;
|
|
29
|
+
--text: #eef6fb;
|
|
30
|
+
--muted: #9fb2be;
|
|
31
|
+
--accent: #a8c8df;
|
|
32
|
+
--accent-strong: #d3ebfb;
|
|
33
|
+
--danger: #ff776d;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
* {
|
|
37
|
+
box-sizing: border-box;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
html,
|
|
41
|
+
body {
|
|
42
|
+
min-height: 100%;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
body {
|
|
46
|
+
align-items: center;
|
|
47
|
+
background:
|
|
48
|
+
radial-gradient(circle at 50% 0%, rgba(168, 200, 223, 0.12), transparent 36rem),
|
|
49
|
+
var(--bg);
|
|
50
|
+
color: var(--text);
|
|
51
|
+
display: flex;
|
|
52
|
+
font-family:
|
|
53
|
+
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
54
|
+
justify-content: center;
|
|
55
|
+
margin: 0;
|
|
56
|
+
padding: 24px;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
main {
|
|
60
|
+
background: rgba(16, 22, 26, 0.95);
|
|
61
|
+
border: 1px solid var(--panel-border);
|
|
62
|
+
border-radius: 8px;
|
|
63
|
+
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.42);
|
|
64
|
+
max-width: 460px;
|
|
65
|
+
padding: 28px;
|
|
66
|
+
width: 100%;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.mark {
|
|
70
|
+
color: var(--accent);
|
|
71
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
72
|
+
font-size: 34px;
|
|
73
|
+
height: 44px;
|
|
74
|
+
line-height: 44px;
|
|
75
|
+
margin-bottom: 18px;
|
|
76
|
+
width: 44px;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
h1 {
|
|
80
|
+
font-size: 24px;
|
|
81
|
+
font-weight: 650;
|
|
82
|
+
letter-spacing: 0;
|
|
83
|
+
line-height: 1.15;
|
|
84
|
+
margin: 0 0 10px;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
p {
|
|
88
|
+
color: var(--muted);
|
|
89
|
+
font-size: 15px;
|
|
90
|
+
line-height: 1.5;
|
|
91
|
+
margin: 0;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
button {
|
|
95
|
+
align-items: center;
|
|
96
|
+
appearance: none;
|
|
97
|
+
background: var(--accent);
|
|
98
|
+
border: 0;
|
|
99
|
+
border-radius: 8px;
|
|
100
|
+
color: #071014;
|
|
101
|
+
cursor: pointer;
|
|
102
|
+
display: inline-flex;
|
|
103
|
+
font: inherit;
|
|
104
|
+
font-weight: 700;
|
|
105
|
+
justify-content: center;
|
|
106
|
+
margin-top: 24px;
|
|
107
|
+
min-height: 46px;
|
|
108
|
+
padding: 0 18px;
|
|
109
|
+
width: 100%;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
button:disabled {
|
|
113
|
+
cursor: wait;
|
|
114
|
+
opacity: 0.72;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.status {
|
|
118
|
+
border-top: 1px solid var(--panel-border);
|
|
119
|
+
color: var(--muted);
|
|
120
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
121
|
+
font-size: 13px;
|
|
122
|
+
line-height: 1.45;
|
|
123
|
+
margin-top: 22px;
|
|
124
|
+
min-height: 20px;
|
|
125
|
+
padding-top: 18px;
|
|
126
|
+
word-break: break-word;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.error {
|
|
130
|
+
color: var(--danger);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.success {
|
|
134
|
+
color: var(--accent-strong);
|
|
135
|
+
}
|
|
136
|
+
</style>
|
|
137
|
+
</head>
|
|
138
|
+
<body>
|
|
139
|
+
<main>
|
|
140
|
+
<div class="mark">◈</div>
|
|
141
|
+
<h1 id="title">Continue with your passkey.</h1>
|
|
142
|
+
<p id="copy">Use the passkey saved in this browser or on a nearby device to finish signing in from the Vex CLI.</p>
|
|
143
|
+
<button id="action" type="button">Continue with passkey</button>
|
|
144
|
+
<div class="status" id="status">Waiting for passkey.</div>
|
|
145
|
+
</main>
|
|
146
|
+
<script>
|
|
147
|
+
"use strict";
|
|
148
|
+
|
|
149
|
+
var params = new URLSearchParams(window.location.hash.slice(1));
|
|
150
|
+
var action = document.getElementById("action");
|
|
151
|
+
var statusEl = document.getElementById("status");
|
|
152
|
+
var titleEl = document.getElementById("title");
|
|
153
|
+
var copyEl = document.getElementById("copy");
|
|
154
|
+
var mode = params.get("mode") || "recover";
|
|
155
|
+
var startupError = null;
|
|
156
|
+
var apiBase = window.location.origin;
|
|
157
|
+
var busy = false;
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
apiBase = resolveTrustedApiBase(params.get("api"));
|
|
161
|
+
} catch (err) {
|
|
162
|
+
startupError = err;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (mode === "register") {
|
|
166
|
+
titleEl.textContent = "Create a passkey for Vex.";
|
|
167
|
+
copyEl.textContent = "This adds the first passkey required by your new CLI account.";
|
|
168
|
+
action.textContent = "Create passkey";
|
|
169
|
+
setStatus("Ready to create passkey.");
|
|
170
|
+
} else {
|
|
171
|
+
setStatus("Ready to verify passkey.");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (startupError) {
|
|
175
|
+
setStatus(errorMessage(startupError), "error");
|
|
176
|
+
action.disabled = true;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
action.addEventListener("click", function () {
|
|
180
|
+
void run();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
function setStatus(message, kind) {
|
|
184
|
+
statusEl.textContent = message;
|
|
185
|
+
statusEl.className = "status" + (kind ? " " + kind : "");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function setBusy(nextBusy) {
|
|
189
|
+
busy = nextBusy;
|
|
190
|
+
action.disabled = nextBusy;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function apiUrl(path) {
|
|
194
|
+
var url = new URL(path, apiBase);
|
|
195
|
+
url.searchParams.set("format", "json");
|
|
196
|
+
return url.toString();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function resolveTrustedApiBase(rawApi) {
|
|
200
|
+
if (!rawApi) {
|
|
201
|
+
return window.location.origin;
|
|
202
|
+
}
|
|
203
|
+
var requested;
|
|
204
|
+
try {
|
|
205
|
+
requested = new URL(rawApi, window.location.origin);
|
|
206
|
+
} catch (_err) {
|
|
207
|
+
throw new Error("Passkey link API origin is invalid.");
|
|
208
|
+
}
|
|
209
|
+
var pageOrigin = new URL(window.location.origin);
|
|
210
|
+
if (requested.origin === pageOrigin.origin) {
|
|
211
|
+
return requested.origin;
|
|
212
|
+
}
|
|
213
|
+
if (isLocalOrigin(pageOrigin) && isLocalOrigin(requested)) {
|
|
214
|
+
return requested.origin;
|
|
215
|
+
}
|
|
216
|
+
throw new Error("Passkey link API origin is not trusted.");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function isLocalOrigin(url) {
|
|
220
|
+
return (
|
|
221
|
+
(url.protocol === "http:" || url.protocol === "https:") &&
|
|
222
|
+
["localhost", "127.0.0.1", "::1", "[::1]"].includes(url.hostname)
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function apiRequest(path, options) {
|
|
227
|
+
var requestOptions = options || {};
|
|
228
|
+
var headers = { Accept: "application/json" };
|
|
229
|
+
if (requestOptions.body !== undefined) {
|
|
230
|
+
headers["Content-Type"] = "application/json";
|
|
231
|
+
}
|
|
232
|
+
if (requestOptions.token) {
|
|
233
|
+
headers.Authorization = "Bearer " + requestOptions.token;
|
|
234
|
+
}
|
|
235
|
+
var response = await fetch(apiUrl(path), {
|
|
236
|
+
body:
|
|
237
|
+
requestOptions.body === undefined
|
|
238
|
+
? undefined
|
|
239
|
+
: JSON.stringify(requestOptions.body),
|
|
240
|
+
headers: headers,
|
|
241
|
+
method: requestOptions.method || "POST",
|
|
242
|
+
});
|
|
243
|
+
var text = await response.text();
|
|
244
|
+
var payload = null;
|
|
245
|
+
if (text.length > 0) {
|
|
246
|
+
try {
|
|
247
|
+
payload = JSON.parse(text);
|
|
248
|
+
} catch (_err) {
|
|
249
|
+
payload = { error: text };
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (!response.ok) {
|
|
253
|
+
throw new Error(
|
|
254
|
+
payload && payload.error
|
|
255
|
+
? String(payload.error)
|
|
256
|
+
: "Request failed with status " + response.status,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
return payload;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function run() {
|
|
263
|
+
if (busy) return;
|
|
264
|
+
setBusy(true);
|
|
265
|
+
try {
|
|
266
|
+
if (!window.PublicKeyCredential || !navigator.credentials) {
|
|
267
|
+
throw new Error("This browser does not support passkeys.");
|
|
268
|
+
}
|
|
269
|
+
if (mode === "register") {
|
|
270
|
+
await registerPasskey();
|
|
271
|
+
} else {
|
|
272
|
+
await recoverWithPasskey();
|
|
273
|
+
}
|
|
274
|
+
} catch (err) {
|
|
275
|
+
setStatus(errorMessage(err), "error");
|
|
276
|
+
action.textContent = mode === "register" ? "Try again" : "Retry passkey";
|
|
277
|
+
} finally {
|
|
278
|
+
setBusy(false);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function errorMessage(err) {
|
|
283
|
+
return err instanceof Error ? err.message : String(err);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function registerPasskey() {
|
|
287
|
+
var token = requiredParam("token");
|
|
288
|
+
var userID = requiredParam("user");
|
|
289
|
+
var username = requiredParam("username");
|
|
290
|
+
var name = params.get("name") || params.get("device") || "vex-chat-cli";
|
|
291
|
+
|
|
292
|
+
setStatus("Requesting passkey challenge for @" + username + "...");
|
|
293
|
+
var begin = await apiRequest("/user/" + encodeURIComponent(userID) + "/passkeys/register/begin", {
|
|
294
|
+
body: { name: name },
|
|
295
|
+
token: token,
|
|
296
|
+
});
|
|
297
|
+
var credentialOptions = makeCreationOptions(begin.options);
|
|
298
|
+
|
|
299
|
+
setStatus("Waiting for browser passkey prompt...");
|
|
300
|
+
var credential = await navigator.credentials.create({
|
|
301
|
+
publicKey: credentialOptions,
|
|
302
|
+
});
|
|
303
|
+
if (!credential) {
|
|
304
|
+
throw new Error("No passkey was created.");
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
setStatus("Saving passkey...");
|
|
308
|
+
await apiRequest("/user/" + encodeURIComponent(userID) + "/passkeys/register/finish", {
|
|
309
|
+
body: {
|
|
310
|
+
name: name,
|
|
311
|
+
requestID: begin.requestID,
|
|
312
|
+
response: registrationResponseJSON(credential),
|
|
313
|
+
},
|
|
314
|
+
token: token,
|
|
315
|
+
});
|
|
316
|
+
setStatus("Passkey saved. Return to the Vex CLI.", "success");
|
|
317
|
+
titleEl.textContent = "Passkey saved.";
|
|
318
|
+
copyEl.textContent = "The CLI can finish connecting now.";
|
|
319
|
+
action.textContent = "Done";
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function recoverWithPasskey() {
|
|
323
|
+
var username = requiredParam("username");
|
|
324
|
+
var requestID = requiredParam("request");
|
|
325
|
+
var code = params.get("code") || "";
|
|
326
|
+
var codeSuffix = code ? " Code: " + code + "." : "";
|
|
327
|
+
|
|
328
|
+
setStatus("Requesting passkey challenge for @" + username + "...");
|
|
329
|
+
var begin = await apiRequest("/auth/passkey/begin", {
|
|
330
|
+
body: { username: username },
|
|
331
|
+
});
|
|
332
|
+
var requestOptions = makeRequestOptions(begin.options);
|
|
333
|
+
|
|
334
|
+
setStatus("Waiting for browser passkey prompt..." + codeSuffix);
|
|
335
|
+
var credential = await navigator.credentials.get({
|
|
336
|
+
publicKey: requestOptions,
|
|
337
|
+
});
|
|
338
|
+
if (!credential) {
|
|
339
|
+
throw new Error("No passkey was returned.");
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
setStatus("Verifying passkey...");
|
|
343
|
+
var auth = await apiRequest("/auth/passkey/finish", {
|
|
344
|
+
body: {
|
|
345
|
+
requestID: begin.requestID,
|
|
346
|
+
response: authenticationResponseJSON(credential),
|
|
347
|
+
},
|
|
348
|
+
});
|
|
349
|
+
var userID =
|
|
350
|
+
auth && auth.user && typeof auth.user.userID === "string"
|
|
351
|
+
? auth.user.userID
|
|
352
|
+
: requiredParam("user");
|
|
353
|
+
var token = auth && typeof auth.token === "string" ? auth.token : "";
|
|
354
|
+
if (!token) {
|
|
355
|
+
throw new Error("Passkey login did not return a recovery token.");
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
setStatus("Recovering CLI device...");
|
|
359
|
+
await apiRequest(
|
|
360
|
+
"/user/" +
|
|
361
|
+
encodeURIComponent(userID) +
|
|
362
|
+
"/passkey/recover/devices/requests/" +
|
|
363
|
+
encodeURIComponent(requestID),
|
|
364
|
+
{
|
|
365
|
+
token: token,
|
|
366
|
+
},
|
|
367
|
+
);
|
|
368
|
+
setStatus("CLI device recovered. Return to the Vex CLI.", "success");
|
|
369
|
+
titleEl.textContent = "Device signed in.";
|
|
370
|
+
copyEl.textContent = "The CLI can finish connecting now.";
|
|
371
|
+
action.textContent = "Done";
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function requiredParam(name) {
|
|
375
|
+
var value = params.get(name);
|
|
376
|
+
if (!value) {
|
|
377
|
+
throw new Error("Missing " + name + " in passkey link.");
|
|
378
|
+
}
|
|
379
|
+
return value;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function makeCreationOptions(options) {
|
|
383
|
+
var converted = Object.assign({}, options);
|
|
384
|
+
converted.challenge = base64UrlToArrayBuffer(options.challenge);
|
|
385
|
+
converted.user = Object.assign({}, options.user, {
|
|
386
|
+
id: base64UrlToArrayBuffer(options.user.id),
|
|
387
|
+
});
|
|
388
|
+
converted.excludeCredentials = (options.excludeCredentials || []).map(
|
|
389
|
+
function (credential) {
|
|
390
|
+
return Object.assign({}, credential, {
|
|
391
|
+
id: base64UrlToArrayBuffer(credential.id),
|
|
392
|
+
});
|
|
393
|
+
},
|
|
394
|
+
);
|
|
395
|
+
return converted;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function makeRequestOptions(options) {
|
|
399
|
+
var converted = Object.assign({}, options);
|
|
400
|
+
converted.challenge = base64UrlToArrayBuffer(options.challenge);
|
|
401
|
+
converted.allowCredentials = (options.allowCredentials || []).map(
|
|
402
|
+
function (credential) {
|
|
403
|
+
return Object.assign({}, credential, {
|
|
404
|
+
id: base64UrlToArrayBuffer(credential.id),
|
|
405
|
+
});
|
|
406
|
+
},
|
|
407
|
+
);
|
|
408
|
+
return converted;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function registrationResponseJSON(credential) {
|
|
412
|
+
var response = credential.response;
|
|
413
|
+
return removeUndefined({
|
|
414
|
+
authenticatorAttachment: credential.authenticatorAttachment,
|
|
415
|
+
clientExtensionResults: credential.getClientExtensionResults(),
|
|
416
|
+
id: credential.id,
|
|
417
|
+
rawId: arrayBufferToBase64Url(credential.rawId),
|
|
418
|
+
response: removeUndefined({
|
|
419
|
+
attestationObject: arrayBufferToBase64Url(response.attestationObject),
|
|
420
|
+
clientDataJSON: arrayBufferToBase64Url(response.clientDataJSON),
|
|
421
|
+
transports:
|
|
422
|
+
typeof response.getTransports === "function"
|
|
423
|
+
? response.getTransports()
|
|
424
|
+
: undefined,
|
|
425
|
+
}),
|
|
426
|
+
type: credential.type,
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function authenticationResponseJSON(credential) {
|
|
431
|
+
var response = credential.response;
|
|
432
|
+
return removeUndefined({
|
|
433
|
+
authenticatorAttachment: credential.authenticatorAttachment,
|
|
434
|
+
clientExtensionResults: credential.getClientExtensionResults(),
|
|
435
|
+
id: credential.id,
|
|
436
|
+
rawId: arrayBufferToBase64Url(credential.rawId),
|
|
437
|
+
response: removeUndefined({
|
|
438
|
+
authenticatorData: arrayBufferToBase64Url(response.authenticatorData),
|
|
439
|
+
clientDataJSON: arrayBufferToBase64Url(response.clientDataJSON),
|
|
440
|
+
signature: arrayBufferToBase64Url(response.signature),
|
|
441
|
+
userHandle: response.userHandle
|
|
442
|
+
? arrayBufferToBase64Url(response.userHandle)
|
|
443
|
+
: null,
|
|
444
|
+
}),
|
|
445
|
+
type: credential.type,
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function removeUndefined(value) {
|
|
450
|
+
Object.keys(value).forEach(function (key) {
|
|
451
|
+
if (value[key] === undefined) {
|
|
452
|
+
delete value[key];
|
|
453
|
+
}
|
|
454
|
+
});
|
|
455
|
+
return value;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function base64UrlToArrayBuffer(value) {
|
|
459
|
+
var base64 = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
460
|
+
var padding = base64.length % 4;
|
|
461
|
+
if (padding === 2) base64 += "==";
|
|
462
|
+
if (padding === 3) base64 += "=";
|
|
463
|
+
if (padding === 1) {
|
|
464
|
+
throw new Error("Invalid base64url value from server.");
|
|
465
|
+
}
|
|
466
|
+
var binary = window.atob(base64);
|
|
467
|
+
var bytes = new Uint8Array(binary.length);
|
|
468
|
+
for (var i = 0; i < binary.length; i += 1) {
|
|
469
|
+
bytes[i] = binary.charCodeAt(i);
|
|
470
|
+
}
|
|
471
|
+
return bytes.buffer;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function arrayBufferToBase64Url(buffer) {
|
|
475
|
+
var bytes = new Uint8Array(buffer);
|
|
476
|
+
var binary = "";
|
|
477
|
+
for (var i = 0; i < bytes.byteLength; i += 1) {
|
|
478
|
+
binary += String.fromCharCode(bytes[i]);
|
|
479
|
+
}
|
|
480
|
+
return window
|
|
481
|
+
.btoa(binary)
|
|
482
|
+
.replace(/\\+/g, "-")
|
|
483
|
+
.replace(/\\//g, "_")
|
|
484
|
+
.replace(/=+$/g, "");
|
|
485
|
+
}
|
|
486
|
+
</script>
|
|
487
|
+
</body>
|
|
488
|
+
</html>`;
|
|
489
|
+
export const getCliPasskeyPageRouter = () => {
|
|
490
|
+
const router = express.Router();
|
|
491
|
+
router.get("/cli/passkey", (_req, res) => {
|
|
492
|
+
res.set({
|
|
493
|
+
"Cache-Control": "no-store",
|
|
494
|
+
"Content-Security-Policy": CSP,
|
|
495
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
496
|
+
"Permissions-Policy": "publickey-credentials-create=(self), publickey-credentials-get=(self)",
|
|
497
|
+
"Referrer-Policy": "no-referrer",
|
|
498
|
+
"X-Content-Type-Options": "nosniff",
|
|
499
|
+
});
|
|
500
|
+
res.status(200).send(CLI_PASSKEY_PAGE);
|
|
501
|
+
});
|
|
502
|
+
return router;
|
|
503
|
+
};
|
|
504
|
+
//# sourceMappingURL=cliPasskeyPage.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cliPasskeyPage.js","sourceRoot":"","sources":["../../src/server/cliPasskeyPage.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,OAAO,MAAM,SAAS,CAAC;AAE9B,MAAM,GAAG,GAAG;IACR,oBAAoB;IACpB,iBAAiB;IACjB,0DAA0D;IAC1D,oBAAoB;IACpB,wBAAwB;IACxB,gBAAgB;IAChB,4BAA4B;IAC5B,2BAA2B;CAC9B,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAEb,MAAM,gBAAgB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;QAudjB,CAAC;AAET,MAAM,CAAC,MAAM,uBAAuB,GAAG,GAAmB,EAAE;IACxD,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;IAEhC,MAAM,CAAC,GAAG,CAAC,cAAc,EAAE,CAAC,IAAI,EAAE,GAAG,EAAE,EAAE;QACrC,GAAG,CAAC,GAAG,CAAC;YACJ,eAAe,EAAE,UAAU;YAC3B,yBAAyB,EAAE,GAAG;YAC9B,cAAc,EAAE,0BAA0B;YAC1C,oBAAoB,EAChB,uEAAuE;YAC3E,iBAAiB,EAAE,aAAa;YAChC,wBAAwB,EAAE,SAAS;SACtC,CAAC,CAAC;QACH,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,gBAAgB,CAAC,CAAC;IAC3C,CAAC,CAAC,CAAC;IAEH,OAAO,MAAM,CAAC;AAClB,CAAC,CAAC"}
|
package/dist/server/index.js
CHANGED
|
@@ -23,6 +23,7 @@ import { msgpack } from "../utils/msgpack.js";
|
|
|
23
23
|
import { verifyPreKeyWsSignature } from "../utils/preKeySignature.js";
|
|
24
24
|
import { spireXSignOpenAsync } from "../utils/spireXSignOpenAsync.js";
|
|
25
25
|
import { getAvatarRouter } from "./avatar.js";
|
|
26
|
+
import { getCliPasskeyPageRouter } from "./cliPasskeyPage.js";
|
|
26
27
|
import { errorHandler } from "./errors.js";
|
|
27
28
|
import { getFileRouter } from "./file.js";
|
|
28
29
|
import { getInviteRouter } from "./invite.js";
|
|
@@ -243,6 +244,10 @@ export const initApp = (api, db, tokenValidator, signKeys, notify, disconnectDev
|
|
|
243
244
|
: true /* reflect request Origin */,
|
|
244
245
|
}));
|
|
245
246
|
api.use(helmet());
|
|
247
|
+
// Browser bridge used by the CLI to run WebAuthn ceremonies at the same
|
|
248
|
+
// origin/RP ID as the API host. The token is carried in the URL fragment,
|
|
249
|
+
// so Spire never receives it when serving the page.
|
|
250
|
+
api.use(getCliPasskeyPageRouter());
|
|
246
251
|
api.use(msgpackParser);
|
|
247
252
|
api.use(checkAuth);
|
|
248
253
|
api.use(createCheckDevice(db));
|