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