@vex-chat/spire 4.0.0 → 4.1.1
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 +580 -91
- 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 +17 -1
- package/src/__tests__/serverManagement.spec.ts +262 -0
- package/src/server/avatar.ts +3 -2
- package/src/server/cliPasskeyPage.ts +580 -91
- package/src/server/index.ts +211 -31
- package/src/server/passkey.ts +480 -3
- package/src/server/serverIcon.ts +199 -0
|
@@ -26,70 +26,138 @@ const CLI_PASSKEY_PAGE = `<!doctype html>
|
|
|
26
26
|
<style>
|
|
27
27
|
:root {
|
|
28
28
|
color-scheme: dark;
|
|
29
|
-
--bg: #
|
|
30
|
-
--panel: #
|
|
31
|
-
--
|
|
32
|
-
--
|
|
33
|
-
--
|
|
34
|
-
--
|
|
35
|
-
--
|
|
36
|
-
--
|
|
29
|
+
--bg: #141519;
|
|
30
|
+
--panel: #202126;
|
|
31
|
+
--border: #2b2d33;
|
|
32
|
+
--border-strong: #41434b;
|
|
33
|
+
--text: #f2f3f5;
|
|
34
|
+
--text-secondary: #dbdee1;
|
|
35
|
+
--muted: #b5bac1;
|
|
36
|
+
--faint: #80848e;
|
|
37
|
+
--accent: #e5484d;
|
|
38
|
+
--accent-hover: #f05b60;
|
|
39
|
+
--accent-soft: rgba(229, 72, 77, 0.16);
|
|
40
|
+
--danger: #f23f42;
|
|
41
|
+
--success: #3ba55c;
|
|
37
42
|
}
|
|
38
43
|
|
|
39
44
|
* {
|
|
40
45
|
box-sizing: border-box;
|
|
41
46
|
}
|
|
42
47
|
|
|
43
|
-
html
|
|
44
|
-
body {
|
|
48
|
+
html {
|
|
45
49
|
min-height: 100%;
|
|
46
50
|
}
|
|
47
51
|
|
|
48
52
|
body {
|
|
49
53
|
align-items: center;
|
|
50
|
-
background:
|
|
51
|
-
radial-gradient(circle at 50% 0%, rgba(168, 200, 223, 0.12), transparent 36rem),
|
|
52
|
-
var(--bg);
|
|
54
|
+
background: var(--bg);
|
|
53
55
|
color: var(--text);
|
|
54
56
|
display: flex;
|
|
55
57
|
font-family:
|
|
56
58
|
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
57
59
|
justify-content: center;
|
|
58
60
|
margin: 0;
|
|
59
|
-
|
|
61
|
+
min-height: 100vh;
|
|
62
|
+
padding: 20px;
|
|
60
63
|
}
|
|
61
64
|
|
|
62
65
|
main {
|
|
63
|
-
background:
|
|
64
|
-
border: 1px solid var(--
|
|
66
|
+
background: var(--panel);
|
|
67
|
+
border: 1px solid var(--border);
|
|
65
68
|
border-radius: 8px;
|
|
66
|
-
box-shadow: 0
|
|
67
|
-
max-width:
|
|
68
|
-
|
|
69
|
+
box-shadow: 0 18px 56px rgba(0, 0, 0, 0.34);
|
|
70
|
+
max-width: 420px;
|
|
71
|
+
overflow: hidden;
|
|
69
72
|
width: 100%;
|
|
70
73
|
}
|
|
71
74
|
|
|
72
|
-
.
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
height:
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
75
|
+
.titlebar {
|
|
76
|
+
align-items: center;
|
|
77
|
+
border-bottom: 1px solid var(--border);
|
|
78
|
+
display: flex;
|
|
79
|
+
min-height: 52px;
|
|
80
|
+
padding: 0 20px;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.wordmark {
|
|
84
|
+
color: var(--text);
|
|
85
|
+
font-family: "Space Grotesk", Inter, ui-sans-serif, system-ui, sans-serif;
|
|
86
|
+
font-size: 17px;
|
|
87
|
+
font-weight: 700;
|
|
88
|
+
line-height: 1;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.titlebar-divider {
|
|
92
|
+
background: var(--border-strong);
|
|
93
|
+
height: 18px;
|
|
94
|
+
margin: 0 11px;
|
|
95
|
+
width: 1px;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.titlebar-context {
|
|
99
|
+
color: var(--muted);
|
|
100
|
+
font-size: 13px;
|
|
101
|
+
font-weight: 600;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.secure-context {
|
|
105
|
+
align-items: center;
|
|
106
|
+
color: var(--faint);
|
|
107
|
+
display: inline-flex;
|
|
108
|
+
font-size: 12px;
|
|
109
|
+
gap: 5px;
|
|
110
|
+
margin-left: auto;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.secure-context svg {
|
|
114
|
+
height: 14px;
|
|
115
|
+
width: 14px;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.content {
|
|
119
|
+
padding: 26px 26px 24px;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.passkey-icon {
|
|
123
|
+
align-items: center;
|
|
124
|
+
background: var(--accent-soft);
|
|
125
|
+
border: 1px solid rgba(229, 72, 77, 0.28);
|
|
126
|
+
border-radius: 8px;
|
|
127
|
+
color: var(--accent-hover);
|
|
128
|
+
display: flex;
|
|
129
|
+
height: 48px;
|
|
130
|
+
justify-content: center;
|
|
131
|
+
margin-bottom: 20px;
|
|
132
|
+
width: 48px;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.passkey-icon svg {
|
|
136
|
+
height: 24px;
|
|
137
|
+
width: 24px;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.eyebrow {
|
|
141
|
+
color: var(--faint);
|
|
142
|
+
font-size: 12px;
|
|
143
|
+
font-weight: 700;
|
|
144
|
+
line-height: 1.3;
|
|
145
|
+
margin: 0 0 7px;
|
|
146
|
+
text-transform: uppercase;
|
|
80
147
|
}
|
|
81
148
|
|
|
82
149
|
h1 {
|
|
83
|
-
font-
|
|
84
|
-
font-
|
|
150
|
+
font-family: "Space Grotesk", Inter, ui-sans-serif, system-ui, sans-serif;
|
|
151
|
+
font-size: 23px;
|
|
152
|
+
font-weight: 700;
|
|
85
153
|
letter-spacing: 0;
|
|
86
|
-
line-height: 1.
|
|
87
|
-
margin: 0 0
|
|
154
|
+
line-height: 1.2;
|
|
155
|
+
margin: 0 0 9px;
|
|
88
156
|
}
|
|
89
157
|
|
|
90
|
-
|
|
158
|
+
.description {
|
|
91
159
|
color: var(--muted);
|
|
92
|
-
font-size:
|
|
160
|
+
font-size: 14px;
|
|
93
161
|
line-height: 1.5;
|
|
94
162
|
margin: 0;
|
|
95
163
|
}
|
|
@@ -98,99 +166,373 @@ const CLI_PASSKEY_PAGE = `<!doctype html>
|
|
|
98
166
|
align-items: center;
|
|
99
167
|
appearance: none;
|
|
100
168
|
background: var(--accent);
|
|
101
|
-
border:
|
|
169
|
+
border: 1px solid var(--accent);
|
|
102
170
|
border-radius: 8px;
|
|
103
|
-
color: #
|
|
171
|
+
color: #ffffff;
|
|
104
172
|
cursor: pointer;
|
|
105
173
|
display: inline-flex;
|
|
106
174
|
font: inherit;
|
|
107
|
-
font-
|
|
175
|
+
font-size: 14px;
|
|
176
|
+
font-weight: 650;
|
|
177
|
+
gap: 9px;
|
|
108
178
|
justify-content: center;
|
|
109
|
-
margin-top:
|
|
110
|
-
min-height:
|
|
179
|
+
margin-top: 22px;
|
|
180
|
+
min-height: 44px;
|
|
111
181
|
padding: 0 18px;
|
|
182
|
+
transition:
|
|
183
|
+
background 120ms ease,
|
|
184
|
+
border-color 120ms ease;
|
|
112
185
|
width: 100%;
|
|
113
186
|
}
|
|
114
187
|
|
|
188
|
+
button:hover:not(:disabled) {
|
|
189
|
+
background: var(--accent-hover);
|
|
190
|
+
border-color: var(--accent-hover);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
button:focus-visible {
|
|
194
|
+
outline: 2px solid var(--text);
|
|
195
|
+
outline-offset: 3px;
|
|
196
|
+
}
|
|
197
|
+
|
|
115
198
|
button:disabled {
|
|
116
199
|
cursor: wait;
|
|
117
|
-
opacity: 0.
|
|
200
|
+
opacity: 0.78;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.spinner {
|
|
204
|
+
animation: spin 760ms linear infinite;
|
|
205
|
+
border: 2px solid rgba(255, 255, 255, 0.4);
|
|
206
|
+
border-radius: 50%;
|
|
207
|
+
border-top-color: #ffffff;
|
|
208
|
+
display: none;
|
|
209
|
+
height: 15px;
|
|
210
|
+
width: 15px;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
button[data-busy="true"] .spinner {
|
|
214
|
+
display: block;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
.helper {
|
|
218
|
+
color: var(--faint);
|
|
219
|
+
font-size: 12px;
|
|
220
|
+
line-height: 1.45;
|
|
221
|
+
margin: 11px 2px 0;
|
|
222
|
+
text-align: center;
|
|
118
223
|
}
|
|
119
224
|
|
|
120
225
|
.status {
|
|
121
|
-
|
|
226
|
+
align-items: flex-start;
|
|
227
|
+
border-top: 1px solid var(--border);
|
|
122
228
|
color: var(--muted);
|
|
123
|
-
|
|
124
|
-
font-size:
|
|
229
|
+
display: flex;
|
|
230
|
+
font-size: 12px;
|
|
231
|
+
gap: 9px;
|
|
125
232
|
line-height: 1.45;
|
|
126
|
-
margin-top:
|
|
233
|
+
margin-top: 20px;
|
|
127
234
|
min-height: 20px;
|
|
128
|
-
padding-top:
|
|
235
|
+
padding-top: 16px;
|
|
129
236
|
word-break: break-word;
|
|
130
237
|
}
|
|
131
238
|
|
|
132
|
-
.
|
|
239
|
+
.status-indicator {
|
|
240
|
+
background: var(--faint);
|
|
241
|
+
border-radius: 2px;
|
|
242
|
+
flex: 0 0 auto;
|
|
243
|
+
height: 7px;
|
|
244
|
+
margin-top: 5px;
|
|
245
|
+
width: 7px;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
main[data-state="busy"] .status-indicator {
|
|
249
|
+
animation: pulse 1.1s ease-in-out infinite;
|
|
250
|
+
background: var(--accent-hover);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
main[data-state="error"] .status {
|
|
133
254
|
color: var(--danger);
|
|
134
255
|
}
|
|
135
256
|
|
|
136
|
-
.
|
|
137
|
-
|
|
257
|
+
main[data-state="error"] .status-indicator {
|
|
258
|
+
background: var(--danger);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
main[data-state="success"] .status {
|
|
262
|
+
color: var(--text-secondary);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
main[data-state="success"] .status-indicator {
|
|
266
|
+
background: var(--success);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
.completion {
|
|
270
|
+
align-items: center;
|
|
271
|
+
color: var(--text-secondary);
|
|
272
|
+
display: flex;
|
|
273
|
+
font-size: 13px;
|
|
274
|
+
gap: 9px;
|
|
275
|
+
line-height: 1.4;
|
|
276
|
+
margin-top: 22px;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
.completion svg {
|
|
280
|
+
color: var(--success);
|
|
281
|
+
flex: 0 0 auto;
|
|
282
|
+
height: 20px;
|
|
283
|
+
width: 20px;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
[hidden] {
|
|
287
|
+
display: none !important;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
@keyframes spin {
|
|
291
|
+
to {
|
|
292
|
+
transform: rotate(360deg);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
@keyframes pulse {
|
|
297
|
+
50% {
|
|
298
|
+
opacity: 0.4;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
@media (max-width: 460px) {
|
|
303
|
+
body {
|
|
304
|
+
padding: 12px;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
.content {
|
|
308
|
+
padding: 24px 22px 22px;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
.titlebar {
|
|
312
|
+
padding: 0 18px;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
@media (max-height: 620px) {
|
|
317
|
+
body {
|
|
318
|
+
align-items: flex-start;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
.content {
|
|
322
|
+
padding-bottom: 20px;
|
|
323
|
+
padding-top: 22px;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
.passkey-icon {
|
|
327
|
+
margin-bottom: 16px;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
button {
|
|
331
|
+
margin-top: 18px;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
.status {
|
|
335
|
+
margin-top: 16px;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
@media (prefers-reduced-motion: reduce) {
|
|
340
|
+
*,
|
|
341
|
+
*::before,
|
|
342
|
+
*::after {
|
|
343
|
+
animation-duration: 0.01ms !important;
|
|
344
|
+
animation-iteration-count: 1 !important;
|
|
345
|
+
scroll-behavior: auto !important;
|
|
346
|
+
transition-duration: 0.01ms !important;
|
|
347
|
+
}
|
|
138
348
|
}
|
|
139
349
|
</style>
|
|
140
350
|
</head>
|
|
141
351
|
<body>
|
|
142
|
-
<main>
|
|
143
|
-
<
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
352
|
+
<main id="surface" data-state="ready">
|
|
353
|
+
<header class="titlebar">
|
|
354
|
+
<span class="wordmark">vex</span>
|
|
355
|
+
<span class="titlebar-divider" aria-hidden="true"></span>
|
|
356
|
+
<span class="titlebar-context" id="titlebar-context">Passkey</span>
|
|
357
|
+
<span class="secure-context">
|
|
358
|
+
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
359
|
+
<rect width="18" height="11" x="3" y="11" rx="2"></rect>
|
|
360
|
+
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
|
|
361
|
+
</svg>
|
|
362
|
+
Secure
|
|
363
|
+
</span>
|
|
364
|
+
</header>
|
|
365
|
+
<section class="content">
|
|
366
|
+
<div class="passkey-icon">
|
|
367
|
+
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
368
|
+
<circle cx="7.5" cy="15.5" r="5.5"></circle>
|
|
369
|
+
<path d="m21 2-9.6 9.6"></path>
|
|
370
|
+
<path d="m15.5 7.5 3 3L22 7l-3-3"></path>
|
|
371
|
+
</svg>
|
|
372
|
+
</div>
|
|
373
|
+
<p class="eyebrow" id="eyebrow">Secure sign in</p>
|
|
374
|
+
<h1 id="title">Continue with your passkey</h1>
|
|
375
|
+
<p class="description" id="copy">Use a passkey saved in this browser or on a nearby device to continue in Vex.</p>
|
|
376
|
+
<button id="action" type="button" data-busy="false">
|
|
377
|
+
<span class="spinner" aria-hidden="true"></span>
|
|
378
|
+
<span id="action-label">Continue with passkey</span>
|
|
379
|
+
</button>
|
|
380
|
+
<p class="helper" id="helper">Your password remains available as a sign-in method.</p>
|
|
381
|
+
<div class="completion" id="completion" hidden>
|
|
382
|
+
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round">
|
|
383
|
+
<path d="M20 6 9 17l-5-5"></path>
|
|
384
|
+
</svg>
|
|
385
|
+
<span id="completion-copy">You can close this page and return to Vex.</span>
|
|
386
|
+
</div>
|
|
387
|
+
<div class="status" id="status" role="status" aria-live="polite">
|
|
388
|
+
<span class="status-indicator" aria-hidden="true"></span>
|
|
389
|
+
<span id="status-copy">Ready for your passkey.</span>
|
|
390
|
+
</div>
|
|
391
|
+
</section>
|
|
148
392
|
</main>
|
|
149
393
|
<script>
|
|
150
394
|
"use strict";
|
|
151
395
|
|
|
152
396
|
var params = new URLSearchParams(window.location.hash.slice(1));
|
|
397
|
+
if (window.location.hash) {
|
|
398
|
+
window.history.replaceState(
|
|
399
|
+
null,
|
|
400
|
+
"",
|
|
401
|
+
window.location.pathname + window.location.search,
|
|
402
|
+
);
|
|
403
|
+
}
|
|
153
404
|
var action = document.getElementById("action");
|
|
154
|
-
var
|
|
405
|
+
var actionLabel = document.getElementById("action-label");
|
|
406
|
+
var completion = document.getElementById("completion");
|
|
407
|
+
var completionCopy = document.getElementById("completion-copy");
|
|
155
408
|
var titleEl = document.getElementById("title");
|
|
156
409
|
var copyEl = document.getElementById("copy");
|
|
410
|
+
var eyebrowEl = document.getElementById("eyebrow");
|
|
411
|
+
var helperEl = document.getElementById("helper");
|
|
412
|
+
var statusCopy = document.getElementById("status-copy");
|
|
413
|
+
var surface = document.getElementById("surface");
|
|
414
|
+
var titlebarContext = document.getElementById("titlebar-context");
|
|
157
415
|
var mode = params.get("mode") || "recover";
|
|
158
416
|
var startupError = null;
|
|
159
417
|
var apiBase = window.location.origin;
|
|
418
|
+
var completionCallback = null;
|
|
419
|
+
var actionDefaultLabel = "Continue with passkey";
|
|
160
420
|
var busy = false;
|
|
421
|
+
var completed = false;
|
|
422
|
+
|
|
423
|
+
configurePage();
|
|
161
424
|
|
|
162
425
|
try {
|
|
163
426
|
apiBase = resolveTrustedApiBase(params.get("api"));
|
|
427
|
+
completionCallback = resolveCompletionCallback(
|
|
428
|
+
params.get("callback"),
|
|
429
|
+
);
|
|
430
|
+
validateLinkParams();
|
|
164
431
|
} catch (err) {
|
|
165
432
|
startupError = err;
|
|
166
433
|
}
|
|
167
434
|
|
|
168
|
-
if (mode === "register") {
|
|
169
|
-
titleEl.textContent = "Create a passkey for Vex.";
|
|
170
|
-
copyEl.textContent = "This adds a passkey to your Vex account.";
|
|
171
|
-
action.textContent = "Create passkey";
|
|
172
|
-
setStatus("Ready to create passkey.");
|
|
173
|
-
} else {
|
|
174
|
-
setStatus("Ready to verify passkey.");
|
|
175
|
-
}
|
|
176
|
-
|
|
177
435
|
if (startupError) {
|
|
178
436
|
setStatus(errorMessage(startupError), "error");
|
|
179
|
-
action.
|
|
437
|
+
action.hidden = true;
|
|
438
|
+
helperEl.textContent =
|
|
439
|
+
"Open a new passkey request from Vex and try again.";
|
|
180
440
|
}
|
|
181
441
|
|
|
182
442
|
action.addEventListener("click", function () {
|
|
183
443
|
void run();
|
|
184
444
|
});
|
|
185
445
|
|
|
446
|
+
function configurePage() {
|
|
447
|
+
document.body.dataset.mode = mode;
|
|
448
|
+
if (mode === "register" || mode === "register-handoff") {
|
|
449
|
+
document.title = "Add a passkey - Vex";
|
|
450
|
+
titlebarContext.textContent = "Add passkey";
|
|
451
|
+
eyebrowEl.textContent = "Account security";
|
|
452
|
+
titleEl.textContent = "Create a passkey";
|
|
453
|
+
copyEl.textContent =
|
|
454
|
+
"Use Face ID, Touch ID, your device PIN, or a security key to add a faster sign-in method.";
|
|
455
|
+
helperEl.textContent =
|
|
456
|
+
"Your password remains available as a sign-in method.";
|
|
457
|
+
actionDefaultLabel = "Create passkey";
|
|
458
|
+
actionLabel.textContent = actionDefaultLabel;
|
|
459
|
+
setStatus("Ready to create your passkey.");
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
if (mode === "authenticate-handoff") {
|
|
463
|
+
document.title = "Sign in with a passkey - Vex";
|
|
464
|
+
titlebarContext.textContent = "Sign in";
|
|
465
|
+
eyebrowEl.textContent = "Desktop sign in";
|
|
466
|
+
titleEl.textContent = "Use your passkey";
|
|
467
|
+
copyEl.textContent =
|
|
468
|
+
"Choose a passkey saved in this browser, on this device, or on a nearby device.";
|
|
469
|
+
helperEl.textContent =
|
|
470
|
+
"Vex will finish signing in automatically after verification.";
|
|
471
|
+
setStatus("Ready to verify your passkey.");
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
document.title = "Recover a device - Vex";
|
|
475
|
+
titlebarContext.textContent = "Device recovery";
|
|
476
|
+
eyebrowEl.textContent = "Trusted device";
|
|
477
|
+
titleEl.textContent = "Verify your passkey";
|
|
478
|
+
copyEl.textContent =
|
|
479
|
+
"Use an account passkey to approve this Vex device.";
|
|
480
|
+
helperEl.textContent =
|
|
481
|
+
"Only continue if you started this request from your device.";
|
|
482
|
+
setStatus("Ready to verify your passkey.");
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function validateLinkParams() {
|
|
486
|
+
if (mode === "register") {
|
|
487
|
+
requiredParam("token");
|
|
488
|
+
requiredParam("user");
|
|
489
|
+
requiredParam("username");
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
if (mode === "register-handoff" || mode === "authenticate-handoff") {
|
|
493
|
+
requiredParam("token");
|
|
494
|
+
requiredParam("request");
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
requiredParam("username");
|
|
498
|
+
requiredParam("request");
|
|
499
|
+
}
|
|
500
|
+
|
|
186
501
|
function setStatus(message, kind) {
|
|
187
|
-
|
|
188
|
-
|
|
502
|
+
statusCopy.textContent = message;
|
|
503
|
+
surface.dataset.state = kind || (busy ? "busy" : "ready");
|
|
189
504
|
}
|
|
190
505
|
|
|
191
506
|
function setBusy(nextBusy) {
|
|
192
507
|
busy = nextBusy;
|
|
193
|
-
action.disabled = nextBusy;
|
|
508
|
+
action.disabled = nextBusy || completed;
|
|
509
|
+
action.dataset.busy = String(nextBusy);
|
|
510
|
+
surface.setAttribute("aria-busy", String(nextBusy));
|
|
511
|
+
if (nextBusy) {
|
|
512
|
+
surface.dataset.state = "busy";
|
|
513
|
+
actionLabel.textContent = "Waiting for passkey";
|
|
514
|
+
} else {
|
|
515
|
+
if (surface.dataset.state === "busy") {
|
|
516
|
+
surface.dataset.state = "ready";
|
|
517
|
+
}
|
|
518
|
+
actionLabel.textContent = actionDefaultLabel;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function showCompletion(title, copy, status, nextStep) {
|
|
523
|
+
completed = true;
|
|
524
|
+
titleEl.textContent = title;
|
|
525
|
+
copyEl.textContent = copy;
|
|
526
|
+
completionCopy.textContent = nextStep;
|
|
527
|
+
completion.hidden = false;
|
|
528
|
+
action.hidden = true;
|
|
529
|
+
helperEl.hidden = true;
|
|
530
|
+
setStatus(status, "success");
|
|
531
|
+
if (completionCallback) {
|
|
532
|
+
window.setTimeout(function () {
|
|
533
|
+
window.location.assign(completionCallback);
|
|
534
|
+
}, 350);
|
|
535
|
+
}
|
|
194
536
|
}
|
|
195
537
|
|
|
196
538
|
function apiUrl(path) {
|
|
@@ -219,6 +561,26 @@ const CLI_PASSKEY_PAGE = `<!doctype html>
|
|
|
219
561
|
throw new Error("Passkey link API origin is not trusted.");
|
|
220
562
|
}
|
|
221
563
|
|
|
564
|
+
function resolveCompletionCallback(rawCallback) {
|
|
565
|
+
if (!rawCallback) return null;
|
|
566
|
+
var callback;
|
|
567
|
+
try {
|
|
568
|
+
callback = new URL(rawCallback);
|
|
569
|
+
} catch (_err) {
|
|
570
|
+
throw new Error("Passkey completion callback is invalid.");
|
|
571
|
+
}
|
|
572
|
+
if (
|
|
573
|
+
callback.protocol !== "vex:" ||
|
|
574
|
+
callback.hostname !== "passkey" ||
|
|
575
|
+
callback.pathname !== "/complete" ||
|
|
576
|
+
callback.search ||
|
|
577
|
+
callback.hash
|
|
578
|
+
) {
|
|
579
|
+
throw new Error("Passkey completion callback is not trusted.");
|
|
580
|
+
}
|
|
581
|
+
return callback.toString();
|
|
582
|
+
}
|
|
583
|
+
|
|
222
584
|
function isLocalOrigin(url) {
|
|
223
585
|
return (
|
|
224
586
|
(url.protocol === "http:" || url.protocol === "https:") &&
|
|
@@ -245,23 +607,39 @@ const CLI_PASSKEY_PAGE = `<!doctype html>
|
|
|
245
607
|
});
|
|
246
608
|
var text = await response.text();
|
|
247
609
|
var payload = null;
|
|
248
|
-
|
|
610
|
+
var contentType = response.headers.get("content-type") || "";
|
|
611
|
+
if (text.length > 0 && contentType.includes("json")) {
|
|
249
612
|
try {
|
|
250
613
|
payload = JSON.parse(text);
|
|
251
614
|
} catch (_err) {
|
|
252
|
-
payload =
|
|
615
|
+
payload = null;
|
|
253
616
|
}
|
|
254
617
|
}
|
|
255
618
|
if (!response.ok) {
|
|
256
|
-
throw new Error(
|
|
257
|
-
payload && payload.error
|
|
258
|
-
? String(payload.error)
|
|
259
|
-
: "Request failed with status " + response.status,
|
|
260
|
-
);
|
|
619
|
+
throw new Error(apiRequestError(response.status, payload));
|
|
261
620
|
}
|
|
262
621
|
return payload;
|
|
263
622
|
}
|
|
264
623
|
|
|
624
|
+
function apiRequestError(status, payload) {
|
|
625
|
+
if (payload && typeof payload.error === "string") {
|
|
626
|
+
return payload.error;
|
|
627
|
+
}
|
|
628
|
+
if (status === 401 || status === 403) {
|
|
629
|
+
return "This passkey request has expired. Return to Vex and start again.";
|
|
630
|
+
}
|
|
631
|
+
if (status === 404) {
|
|
632
|
+
return "This passkey request is no longer available. Return to Vex and start again.";
|
|
633
|
+
}
|
|
634
|
+
if (status === 429) {
|
|
635
|
+
return "Too many attempts. Wait a moment, then try again.";
|
|
636
|
+
}
|
|
637
|
+
if (status >= 500) {
|
|
638
|
+
return "Vex could not complete the passkey request. Try again.";
|
|
639
|
+
}
|
|
640
|
+
return "The passkey request could not be completed. Try again.";
|
|
641
|
+
}
|
|
642
|
+
|
|
265
643
|
async function run() {
|
|
266
644
|
if (busy) return;
|
|
267
645
|
setBusy(true);
|
|
@@ -271,18 +649,47 @@ const CLI_PASSKEY_PAGE = `<!doctype html>
|
|
|
271
649
|
}
|
|
272
650
|
if (mode === "register") {
|
|
273
651
|
await registerPasskey();
|
|
652
|
+
} else if (mode === "register-handoff") {
|
|
653
|
+
await registerPasskeyWithHandoff();
|
|
654
|
+
} else if (mode === "authenticate-handoff") {
|
|
655
|
+
await authenticatePasskeyWithHandoff();
|
|
274
656
|
} else {
|
|
275
657
|
await recoverWithPasskey();
|
|
276
658
|
}
|
|
277
659
|
} catch (err) {
|
|
660
|
+
actionDefaultLabel =
|
|
661
|
+
mode === "register" || mode === "register-handoff"
|
|
662
|
+
? "Try again"
|
|
663
|
+
: "Retry passkey";
|
|
278
664
|
setStatus(errorMessage(err), "error");
|
|
279
|
-
action.textContent = mode === "register" ? "Try again" : "Retry passkey";
|
|
280
665
|
} finally {
|
|
281
666
|
setBusy(false);
|
|
282
667
|
}
|
|
283
668
|
}
|
|
284
669
|
|
|
285
670
|
function errorMessage(err) {
|
|
671
|
+
if (err && err.name === "NotAllowedError") {
|
|
672
|
+
return mode === "register" || mode === "register-handoff"
|
|
673
|
+
? "Passkey creation was canceled or blocked by this browser. Check the browser prompt, then try again."
|
|
674
|
+
: "Passkey verification was canceled or timed out. Try again when you are ready.";
|
|
675
|
+
}
|
|
676
|
+
if (err && err.name === "InvalidStateError") {
|
|
677
|
+
return "This passkey is already registered with Vex. Try a different passkey.";
|
|
678
|
+
}
|
|
679
|
+
if (err && err.name === "SecurityError") {
|
|
680
|
+
return "This browser cannot use passkeys from the current Vex address.";
|
|
681
|
+
}
|
|
682
|
+
if (err && err.name === "AbortError") {
|
|
683
|
+
return "The passkey request was canceled. Try again when you are ready.";
|
|
684
|
+
}
|
|
685
|
+
if (
|
|
686
|
+
err &&
|
|
687
|
+
(err.name === "NetworkError" ||
|
|
688
|
+
(err.name === "TypeError" &&
|
|
689
|
+
/fetch|network|load failed/i.test(err.message || "")))
|
|
690
|
+
) {
|
|
691
|
+
return "Vex could not reach the server. Check your connection and try again.";
|
|
692
|
+
}
|
|
286
693
|
return err instanceof Error ? err.message : String(err);
|
|
287
694
|
}
|
|
288
695
|
|
|
@@ -297,15 +704,8 @@ const CLI_PASSKEY_PAGE = `<!doctype html>
|
|
|
297
704
|
body: { name: name },
|
|
298
705
|
token: token,
|
|
299
706
|
});
|
|
300
|
-
var credentialOptions = makeCreationOptions(begin.options);
|
|
301
|
-
|
|
302
707
|
setStatus("Waiting for browser passkey prompt...");
|
|
303
|
-
var credential = await
|
|
304
|
-
publicKey: credentialOptions,
|
|
305
|
-
});
|
|
306
|
-
if (!credential) {
|
|
307
|
-
throw new Error("No passkey was created.");
|
|
308
|
-
}
|
|
708
|
+
var credential = await createPasskeyCredential(begin.options);
|
|
309
709
|
|
|
310
710
|
setStatus("Saving passkey...");
|
|
311
711
|
await apiRequest("/user/" + encodeURIComponent(userID) + "/passkeys/register/finish", {
|
|
@@ -316,10 +716,97 @@ const CLI_PASSKEY_PAGE = `<!doctype html>
|
|
|
316
716
|
},
|
|
317
717
|
token: token,
|
|
318
718
|
});
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
719
|
+
showCompletion(
|
|
720
|
+
"Passkey saved",
|
|
721
|
+
"Your new passkey is ready to use with Vex.",
|
|
722
|
+
"Passkey added to your account.",
|
|
723
|
+
"Return to the Vex CLI. It will finish connecting automatically.",
|
|
724
|
+
);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
async function registerPasskeyWithHandoff() {
|
|
728
|
+
var token = requiredParam("token");
|
|
729
|
+
var requestID = requiredParam("request");
|
|
730
|
+
|
|
731
|
+
setStatus("Requesting a one-time passkey challenge...");
|
|
732
|
+
var begin = await apiRequest(
|
|
733
|
+
"/auth/passkey/browser-registration/" +
|
|
734
|
+
encodeURIComponent(requestID) +
|
|
735
|
+
"/begin",
|
|
736
|
+
{ body: { token: token } },
|
|
737
|
+
);
|
|
738
|
+
|
|
739
|
+
setStatus("Waiting for browser passkey prompt...");
|
|
740
|
+
var credential = await createPasskeyCredential(begin.options);
|
|
741
|
+
|
|
742
|
+
setStatus("Saving passkey...");
|
|
743
|
+
await apiRequest(
|
|
744
|
+
"/auth/passkey/browser-registration/" +
|
|
745
|
+
encodeURIComponent(requestID) +
|
|
746
|
+
"/finish",
|
|
747
|
+
{
|
|
748
|
+
body: {
|
|
749
|
+
response: registrationResponseJSON(credential),
|
|
750
|
+
token: token,
|
|
751
|
+
},
|
|
752
|
+
},
|
|
753
|
+
);
|
|
754
|
+
showCompletion(
|
|
755
|
+
"Passkey saved",
|
|
756
|
+
"Your new passkey is ready to use with Vex.",
|
|
757
|
+
"Passkey added to your account.",
|
|
758
|
+
"You can close this page and return to Vex.",
|
|
759
|
+
);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
async function createPasskeyCredential(options) {
|
|
763
|
+
var credential = await navigator.credentials.create({
|
|
764
|
+
publicKey: makeCreationOptions(options),
|
|
765
|
+
});
|
|
766
|
+
if (!credential) {
|
|
767
|
+
throw new Error("No passkey was created.");
|
|
768
|
+
}
|
|
769
|
+
return credential;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
async function authenticatePasskeyWithHandoff() {
|
|
773
|
+
var token = requiredParam("token");
|
|
774
|
+
var requestID = requiredParam("request");
|
|
775
|
+
|
|
776
|
+
setStatus("Requesting a one-time passkey challenge...");
|
|
777
|
+
var begin = await apiRequest(
|
|
778
|
+
"/auth/passkey/browser-authentication/" +
|
|
779
|
+
encodeURIComponent(requestID) +
|
|
780
|
+
"/begin",
|
|
781
|
+
{ body: { token: token } },
|
|
782
|
+
);
|
|
783
|
+
|
|
784
|
+
setStatus("Waiting for browser passkey prompt...");
|
|
785
|
+
var credential = await navigator.credentials.get({
|
|
786
|
+
publicKey: makeRequestOptions(begin.options),
|
|
787
|
+
});
|
|
788
|
+
if (!credential) {
|
|
789
|
+
throw new Error("No passkey was returned.");
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
setStatus("Returning verification to Vex...");
|
|
793
|
+
await apiRequest(
|
|
794
|
+
"/auth/passkey/browser-authentication/" +
|
|
795
|
+
encodeURIComponent(requestID) +
|
|
796
|
+
"/finish",
|
|
797
|
+
{
|
|
798
|
+
body: {
|
|
799
|
+
response: authenticationResponseJSON(credential),
|
|
800
|
+
token: token,
|
|
801
|
+
},
|
|
802
|
+
},
|
|
803
|
+
);
|
|
804
|
+
showCompletion(
|
|
805
|
+
"You are verified",
|
|
806
|
+
"Vex can finish signing you in on the desktop.",
|
|
807
|
+
"Passkey verified successfully.",
|
|
808
|
+
"You can close this page and return to Vex.",
|
|
809
|
+
);
|
|
323
810
|
}
|
|
324
811
|
|
|
325
812
|
async function recoverWithPasskey() {
|
|
@@ -368,10 +855,12 @@ const CLI_PASSKEY_PAGE = `<!doctype html>
|
|
|
368
855
|
token: token,
|
|
369
856
|
},
|
|
370
857
|
);
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
858
|
+
showCompletion(
|
|
859
|
+
"Device approved",
|
|
860
|
+
"Your Vex device can finish connecting now.",
|
|
861
|
+
"Device approved with your passkey.",
|
|
862
|
+
"Return to the Vex CLI. It will finish connecting automatically.",
|
|
863
|
+
);
|
|
375
864
|
}
|
|
376
865
|
|
|
377
866
|
function requiredParam(name) {
|