@excom/web-authn 0.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/.rush/temp/chunked-rush-logs/web-authn.apply-exports.chunks.jsonl +1 -0
- package/.rush/temp/chunked-rush-logs/web-authn.build_docs.chunks.jsonl +1 -0
- package/.rush/temp/chunked-rush-logs/web-authn.build_package-metas.chunks.jsonl +1 -0
- package/.rush/temp/operation/apply-exports/all.log +1 -0
- package/.rush/temp/operation/apply-exports/log-chunks.jsonl +1 -0
- package/.rush/temp/operation/apply-exports/state.json +3 -0
- package/.rush/temp/operation/build_docs/all.log +1 -0
- package/.rush/temp/operation/build_docs/log-chunks.jsonl +1 -0
- package/.rush/temp/operation/build_docs/state.json +3 -0
- package/.rush/temp/operation/build_package-metas/all.log +1 -0
- package/.rush/temp/operation/build_package-metas/log-chunks.jsonl +1 -0
- package/.rush/temp/operation/build_package-metas/state.json +3 -0
- package/.rush/temp/shrinkwrap-deps.json +4 -0
- package/config/rig.json +5 -0
- package/index.ts +17 -0
- package/package.json +44 -0
- package/rush-logs/web-authn.apply-exports.cache.log +1 -0
- package/rush-logs/web-authn.apply-exports.log +1 -0
- package/rush-logs/web-authn.build_docs.cache.log +1 -0
- package/rush-logs/web-authn.build_docs.log +1 -0
- package/rush-logs/web-authn.build_package-metas.cache.log +1 -0
- package/rush-logs/web-authn.build_package-metas.log +1 -0
- package/support/custom-elements.json +156 -0
- package/support/demos/authenticate.html +24 -0
- package/support/demos/register.html +24 -0
- package/support/dist-docs/web-authn.md +205 -0
- package/support/docs/README.md +81 -0
- package/support/package-meta.json +240 -0
- package/support/tests/authenticate.view.test.ts +28 -0
- package/support/tests/register.view.test.ts +29 -0
- package/support/tests/web-authn.test.ts +566 -0
- package/tsconfig.json +5 -0
- package/web-authn.ts +219 -0
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
import { invokeCommand } from "@excom/neutron";
|
|
2
|
+
import "../../index";
|
|
3
|
+
import {
|
|
4
|
+
afterEach,
|
|
5
|
+
describe,
|
|
6
|
+
expect,
|
|
7
|
+
fixture,
|
|
8
|
+
it,
|
|
9
|
+
waitForEvent,
|
|
10
|
+
wait,
|
|
11
|
+
} from "@excom/heft-rig/profiles/default/config/test-utils";
|
|
12
|
+
import {
|
|
13
|
+
startRegistration,
|
|
14
|
+
startAuthentication,
|
|
15
|
+
} from "@simplewebauthn/browser";
|
|
16
|
+
|
|
17
|
+
const vi = (globalThis as any).vi;
|
|
18
|
+
|
|
19
|
+
vi.mock("@simplewebauthn/browser", () => ({
|
|
20
|
+
startRegistration: vi.fn(),
|
|
21
|
+
startAuthentication: vi.fn(),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
const mockStartRegistration = vi.mocked(startRegistration);
|
|
25
|
+
const mockStartAuthentication = vi.mocked(startAuthentication);
|
|
26
|
+
|
|
27
|
+
function mockOptionsEndpoint(options = { challenge: "abc123" }) {
|
|
28
|
+
return vi.spyOn(globalThis, "fetch").mockImplementation(() =>
|
|
29
|
+
Promise.resolve(
|
|
30
|
+
new Response(JSON.stringify(options), {
|
|
31
|
+
status: 200,
|
|
32
|
+
headers: { "content-type": "application/json" },
|
|
33
|
+
}),
|
|
34
|
+
),
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe("web-authn", () => {
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
document.body.innerHTML = "";
|
|
41
|
+
vi.restoreAllMocks();
|
|
42
|
+
mockStartRegistration.mockReset();
|
|
43
|
+
mockStartAuthentication.mockReset();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("defines the web-authn custom element", () => {
|
|
47
|
+
expect(customElements.get("web-authn")).toBeTruthy();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("finds form element on connect", () => {
|
|
51
|
+
const el = fixture<HTMLWebAuthnElement>(
|
|
52
|
+
`<web-authn><form><input name="username" value="alice"></form></web-authn>`,
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
expect(el.getFormElement()).toBeInstanceOf(HTMLFormElement);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("emits error when required attributes are missing", () => {
|
|
59
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
60
|
+
|
|
61
|
+
const el = fixture<HTMLWebAuthnElement>(
|
|
62
|
+
`<web-authn><form></form></web-authn>`,
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const spy = vi.fn();
|
|
66
|
+
el.addEventListener("web-authn-error", spy);
|
|
67
|
+
|
|
68
|
+
el.querySelector("form")!.dispatchEvent(
|
|
69
|
+
new Event("submit", { cancelable: true, bubbles: true }),
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
expect(spy).toHaveBeenCalled();
|
|
73
|
+
expect(errorSpy).toHaveBeenCalled();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("prevents default on form submit", () => {
|
|
77
|
+
const el = fixture<HTMLWebAuthnElement>(
|
|
78
|
+
`<web-authn options-url="/options" verify-url="/verify" start-method="register">
|
|
79
|
+
<form><input name="username" value="alice"></form>
|
|
80
|
+
</web-authn>`,
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
mockOptionsEndpoint();
|
|
84
|
+
mockStartRegistration.mockResolvedValue({ id: "cred-1" });
|
|
85
|
+
|
|
86
|
+
const event = new Event("submit", { cancelable: true, bubbles: true });
|
|
87
|
+
const preventSpy = vi.spyOn(event, "preventDefault");
|
|
88
|
+
|
|
89
|
+
el.querySelector("form")!.dispatchEvent(event);
|
|
90
|
+
|
|
91
|
+
expect(preventSpy).toHaveBeenCalled();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("calls startRegistration during register flow", async () => {
|
|
95
|
+
const fetchSpy = mockOptionsEndpoint({ challenge: "reg-challenge" });
|
|
96
|
+
mockStartRegistration.mockResolvedValue({ id: "cred-1", type: "public-key" });
|
|
97
|
+
|
|
98
|
+
const el = fixture<HTMLWebAuthnElement>(
|
|
99
|
+
`<web-authn options-url="/api/register/options" verify-url="/api/register/verify" start-method="register">
|
|
100
|
+
<form><input name="username" value="alice"></form>
|
|
101
|
+
</web-authn>`,
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
const triggerPromise = waitForEvent(el, "web-authn-submit");
|
|
105
|
+
|
|
106
|
+
el.querySelector("form")!.dispatchEvent(
|
|
107
|
+
new Event("submit", { cancelable: true, bubbles: true }),
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
await triggerPromise;
|
|
111
|
+
|
|
112
|
+
expect(fetchSpy).toHaveBeenCalledWith(
|
|
113
|
+
"http://localhost:3000/api/register/options",
|
|
114
|
+
expect.objectContaining({ method: "POST" }),
|
|
115
|
+
);
|
|
116
|
+
expect(mockStartRegistration).toHaveBeenCalledWith({
|
|
117
|
+
optionsJSON: { challenge: "reg-challenge" },
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("calls startAuthentication during authenticate flow", async () => {
|
|
122
|
+
mockOptionsEndpoint({ challenge: "auth-challenge" });
|
|
123
|
+
mockStartAuthentication.mockResolvedValue({ id: "cred-2", response: {} });
|
|
124
|
+
|
|
125
|
+
const el = fixture<HTMLWebAuthnElement>(
|
|
126
|
+
`<web-authn options-url="/api/auth/options" verify-url="/api/auth/verify" start-method="authenticate">
|
|
127
|
+
<form><input name="username" value="bob"></form>
|
|
128
|
+
</web-authn>`,
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const triggerPromise = waitForEvent(el, "web-authn-submit");
|
|
132
|
+
|
|
133
|
+
el.querySelector("form")!.dispatchEvent(
|
|
134
|
+
new Event("submit", { cancelable: true, bubbles: true }),
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
await triggerPromise;
|
|
138
|
+
|
|
139
|
+
expect(mockStartAuthentication).toHaveBeenCalledWith({
|
|
140
|
+
optionsJSON: { challenge: "auth-challenge" },
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("emits error when options fetch fails", async () => {
|
|
145
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
146
|
+
vi.spyOn(globalThis, "fetch").mockImplementation(() =>
|
|
147
|
+
Promise.resolve(
|
|
148
|
+
new Response(null, { status: 500, statusText: "Internal Server Error" }),
|
|
149
|
+
),
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
const el = fixture<HTMLWebAuthnElement>(
|
|
153
|
+
`<web-authn options-url="/api/options" verify-url="/api/verify" start-method="register">
|
|
154
|
+
<form><input name="username" value="alice"></form>
|
|
155
|
+
</web-authn>`,
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
const errorPromise = waitForEvent(el, "web-authn-error");
|
|
159
|
+
|
|
160
|
+
el.querySelector("form")!.dispatchEvent(
|
|
161
|
+
new Event("submit", { cancelable: true, bubbles: true }),
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
await errorPromise;
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("emits error when startMethod is invalid", async () => {
|
|
168
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
169
|
+
mockOptionsEndpoint();
|
|
170
|
+
|
|
171
|
+
const el = fixture<HTMLWebAuthnElement>(
|
|
172
|
+
`<web-authn options-url="/api/options" verify-url="/api/verify" start-method="invalid">
|
|
173
|
+
<form><input name="username" value="alice"></form>
|
|
174
|
+
</web-authn>`,
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
const errorPromise = waitForEvent(el, "web-authn-error");
|
|
178
|
+
|
|
179
|
+
el.querySelector("form")!.dispatchEvent(
|
|
180
|
+
new Event("submit", { cancelable: true, bubbles: true }),
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
await errorPromise;
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("emits error when startRegistration rejects", async () => {
|
|
187
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
188
|
+
mockOptionsEndpoint();
|
|
189
|
+
mockStartRegistration.mockRejectedValue(new Error("User canceled"));
|
|
190
|
+
|
|
191
|
+
const el = fixture<HTMLWebAuthnElement>(
|
|
192
|
+
`<web-authn options-url="/api/options" verify-url="/api/verify" start-method="register">
|
|
193
|
+
<form><input name="username" value="alice"></form>
|
|
194
|
+
</web-authn>`,
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
const errorPromise = waitForEvent(el, "web-authn-error");
|
|
198
|
+
|
|
199
|
+
el.querySelector("form")!.dispatchEvent(
|
|
200
|
+
new Event("submit", { cancelable: true, bubbles: true }),
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
await errorPromise;
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("sends form data as JSON in the options fetch body", async () => {
|
|
207
|
+
const fetchSpy = mockOptionsEndpoint();
|
|
208
|
+
mockStartRegistration.mockResolvedValue({ id: "cred-1" });
|
|
209
|
+
|
|
210
|
+
const el = fixture<HTMLWebAuthnElement>(
|
|
211
|
+
`<web-authn options-url="/api/options" verify-url="/api/verify" start-method="register">
|
|
212
|
+
<form>
|
|
213
|
+
<input name="username" value="alice">
|
|
214
|
+
<input name="displayName" value="Alice">
|
|
215
|
+
</form>
|
|
216
|
+
</web-authn>`,
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
const triggerPromise = waitForEvent(el, "web-authn-submit");
|
|
220
|
+
|
|
221
|
+
el.querySelector("form")!.dispatchEvent(
|
|
222
|
+
new Event("submit", { cancelable: true, bubbles: true }),
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
await triggerPromise;
|
|
226
|
+
|
|
227
|
+
const [, init] = fetchSpy.mock.calls[0];
|
|
228
|
+
const body = JSON.parse(init.body);
|
|
229
|
+
expect(body.username).toBe("alice");
|
|
230
|
+
expect(body.displayName).toBe("Alice");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("interpolates params in verifyUrl via trigger → load event", async () => {
|
|
234
|
+
mockOptionsEndpoint();
|
|
235
|
+
mockStartRegistration.mockResolvedValue({ id: "cred-1" });
|
|
236
|
+
|
|
237
|
+
const el = fixture<HTMLWebAuthnElement>(
|
|
238
|
+
`<web-authn options-url="/api/options" verify-url="/api/42/verify" start-method="register">
|
|
239
|
+
<form><input name="username" value="alice"></form>
|
|
240
|
+
</web-authn>`,
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
let loadDetail: any;
|
|
244
|
+
el.addEventListener("web-authn-submit", (e: any) => {
|
|
245
|
+
loadDetail = e.detail;
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
const triggerPromise = waitForEvent(el, "web-authn-submit");
|
|
249
|
+
|
|
250
|
+
el.querySelector("form")!.dispatchEvent(
|
|
251
|
+
new Event("submit", { cancelable: true, bubbles: true }),
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
await triggerPromise;
|
|
255
|
+
|
|
256
|
+
expect(loadDetail[0]).toBe("http://localhost:3000/api/42/verify");
|
|
257
|
+
expect(loadDetail[1].method).toBe("POST");
|
|
258
|
+
expect(loadDetail[1].credentials).toBe("include");
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
describe("--submit command", () => {
|
|
262
|
+
it("starts the ceremony; the command does not bubble", async () => {
|
|
263
|
+
mockOptionsEndpoint({ challenge: "trig" });
|
|
264
|
+
mockStartRegistration.mockResolvedValue({ id: "cred-1" });
|
|
265
|
+
|
|
266
|
+
const el = fixture<HTMLWebAuthnElement>(
|
|
267
|
+
`<web-authn options-url="/api/options" verify-url="/api/verify" start-method="register">
|
|
268
|
+
<form><input name="username" value="alice"></form>
|
|
269
|
+
</web-authn>`,
|
|
270
|
+
);
|
|
271
|
+
const outer = vi.fn();
|
|
272
|
+
el.parentElement!.addEventListener("command", outer);
|
|
273
|
+
|
|
274
|
+
const submitted = waitForEvent(el, "web-authn-submit");
|
|
275
|
+
invokeCommand(el, "--submit");
|
|
276
|
+
// the ceremony starts once the dispatch settles (a microtask)
|
|
277
|
+
await Promise.resolve();
|
|
278
|
+
expect(el.isLoading).toBe(true);
|
|
279
|
+
await submitted;
|
|
280
|
+
|
|
281
|
+
expect(outer).not.toHaveBeenCalled();
|
|
282
|
+
expect(mockStartRegistration).toHaveBeenCalledWith({
|
|
283
|
+
optionsJSON: { challenge: "trig" },
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("drives a form-ref outside the subtree that submit cannot reach", async () => {
|
|
288
|
+
const fetchSpy = mockOptionsEndpoint();
|
|
289
|
+
mockStartAuthentication.mockResolvedValue({ id: "cred-2" });
|
|
290
|
+
|
|
291
|
+
const wrap = fixture<HTMLDivElement>(
|
|
292
|
+
`<div>
|
|
293
|
+
<form id="ext-form"><input name="username" value="carol"></form>
|
|
294
|
+
<web-authn form-ref="#ext-form" options-url="/api/options" verify-url="/api/verify" start-method="authenticate"></web-authn>
|
|
295
|
+
</div>`,
|
|
296
|
+
);
|
|
297
|
+
const el = wrap.querySelector("web-authn")!;
|
|
298
|
+
const form = wrap.querySelector("form")!;
|
|
299
|
+
expect(el.getFormElement()).toBe(form);
|
|
300
|
+
|
|
301
|
+
// the element is not an ancestor of the form, so submit is not heard
|
|
302
|
+
form.dispatchEvent(new Event("submit", { cancelable: true, bubbles: true }));
|
|
303
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
304
|
+
|
|
305
|
+
await waitForEvent(el, "web-authn-submit", () => {
|
|
306
|
+
invokeCommand(el, "--submit");
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
const [optionsUrl, optionsInit] = fetchSpy.mock.calls[0];
|
|
310
|
+
expect(optionsUrl).toBe("http://localhost:3000/api/options");
|
|
311
|
+
expect(JSON.parse(optionsInit.body).username).toBe("carol");
|
|
312
|
+
expect(mockStartAuthentication).toHaveBeenCalled();
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
describe("option parsing", () => {
|
|
317
|
+
it("emits error when form-ref matches no form", async () => {
|
|
318
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
319
|
+
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
|
320
|
+
|
|
321
|
+
const el = fixture<HTMLWebAuthnElement>(
|
|
322
|
+
`<web-authn form-ref="#nope" options-url="/api/options" verify-url="/api/verify" start-method="register">
|
|
323
|
+
<form></form>
|
|
324
|
+
</web-authn>`,
|
|
325
|
+
);
|
|
326
|
+
const spy = vi.fn();
|
|
327
|
+
el.addEventListener("web-authn-error", spy);
|
|
328
|
+
|
|
329
|
+
invokeCommand(el, "--submit");
|
|
330
|
+
await wait(0);
|
|
331
|
+
|
|
332
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
333
|
+
expect(spy.mock.calls[0][0].detail).toMatch(/Missing required/);
|
|
334
|
+
expect(errorSpy).toHaveBeenCalled();
|
|
335
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
336
|
+
expect(el.isLoading).toBeFalsy();
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it.each([
|
|
340
|
+
["options-url", `verify-url="/v" start-method="register"`],
|
|
341
|
+
["verify-url", `options-url="/o" start-method="register"`],
|
|
342
|
+
["start-method", `options-url="/o" verify-url="/v"`],
|
|
343
|
+
])("emits error when %s is missing", (_name, attrs) => {
|
|
344
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
345
|
+
const el = fixture<HTMLWebAuthnElement>(
|
|
346
|
+
`<web-authn ${attrs}><form></form></web-authn>`,
|
|
347
|
+
);
|
|
348
|
+
const spy = vi.fn();
|
|
349
|
+
el.addEventListener("web-authn-error", spy);
|
|
350
|
+
|
|
351
|
+
el.querySelector("form")!.dispatchEvent(
|
|
352
|
+
new Event("submit", { cancelable: true, bubbles: true }),
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it("uses api-method for both the options and verify requests", async () => {
|
|
359
|
+
const fetchSpy = mockOptionsEndpoint();
|
|
360
|
+
mockStartRegistration.mockResolvedValue({ id: "cred-1" });
|
|
361
|
+
|
|
362
|
+
const el = fixture<HTMLWebAuthnElement>(
|
|
363
|
+
`<web-authn api-method="put" options-url="/api/options" verify-url="/api/verify" start-method="register">
|
|
364
|
+
<form><input name="username" value="alice"></form>
|
|
365
|
+
</web-authn>`,
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
let submitDetail: any;
|
|
369
|
+
el.addEventListener("web-authn-submit", (e: any) => {
|
|
370
|
+
submitDetail = e.detail;
|
|
371
|
+
});
|
|
372
|
+
await waitForEvent(el, "web-authn-submit", () => {
|
|
373
|
+
el.querySelector("form")!.dispatchEvent(
|
|
374
|
+
new Event("submit", { cancelable: true, bubbles: true }),
|
|
375
|
+
);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
expect(fetchSpy.mock.calls[0][1].method).toBe("PUT");
|
|
379
|
+
expect(submitDetail[1].method).toBe("PUT");
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it("carries the credential as the verify request body", async () => {
|
|
383
|
+
mockOptionsEndpoint();
|
|
384
|
+
const credential = { id: "cred-9", type: "public-key", response: { x: 1 } };
|
|
385
|
+
mockStartRegistration.mockResolvedValue(credential);
|
|
386
|
+
|
|
387
|
+
const el = fixture<HTMLWebAuthnElement>(
|
|
388
|
+
`<web-authn options-url="/api/options" verify-url="/api/verify" start-method="register">
|
|
389
|
+
<form><input name="username" value="alice"></form>
|
|
390
|
+
</web-authn>`,
|
|
391
|
+
);
|
|
392
|
+
let submitDetail: any;
|
|
393
|
+
el.addEventListener("web-authn-submit", (e: any) => {
|
|
394
|
+
submitDetail = e.detail;
|
|
395
|
+
});
|
|
396
|
+
await waitForEvent(el, "web-authn-submit", () => {
|
|
397
|
+
el.querySelector("form")!.dispatchEvent(
|
|
398
|
+
new Event("submit", { cancelable: true, bubbles: true }),
|
|
399
|
+
);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
expect(submitDetail[1].body).toEqual(credential);
|
|
403
|
+
});
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
describe("verify request", () => {
|
|
407
|
+
const mockCeremony = (verify: { status: number; body: unknown }) =>
|
|
408
|
+
vi.spyOn(globalThis, "fetch").mockImplementation((url: any) =>
|
|
409
|
+
Promise.resolve(
|
|
410
|
+
new Response(
|
|
411
|
+
JSON.stringify(
|
|
412
|
+
String(url).includes("/options") ? { challenge: "c" } : verify.body,
|
|
413
|
+
),
|
|
414
|
+
{
|
|
415
|
+
status: String(url).includes("/options") ? 200 : verify.status,
|
|
416
|
+
headers: { "content-type": "application/json" },
|
|
417
|
+
},
|
|
418
|
+
),
|
|
419
|
+
),
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
it("posts the credential to verify-url and publishes provision on success", async () => {
|
|
423
|
+
const fetchSpy = mockCeremony({ status: 200, body: { verified: true } });
|
|
424
|
+
const credential = { id: "cred-1", type: "public-key" };
|
|
425
|
+
mockStartRegistration.mockResolvedValue(credential);
|
|
426
|
+
|
|
427
|
+
const el = fixture<HTMLWebAuthnElement>(
|
|
428
|
+
`<web-authn options-url="/api/register/options" verify-url="/api/register/verify" start-method="register">
|
|
429
|
+
<form><input name="username" value="alice"></form>
|
|
430
|
+
</web-authn>`,
|
|
431
|
+
);
|
|
432
|
+
const provisionSpy = vi.fn();
|
|
433
|
+
el.addEventListener("neutron-provision", provisionSpy);
|
|
434
|
+
const successSpy = vi.fn();
|
|
435
|
+
el.addEventListener("web-authn-success", successSpy);
|
|
436
|
+
|
|
437
|
+
await waitForEvent(el, "web-authn-success", () => {
|
|
438
|
+
el.querySelector("form")!.dispatchEvent(
|
|
439
|
+
new Event("submit", { cancelable: true, bubbles: true }),
|
|
440
|
+
);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
444
|
+
const [verifyUrl, verifyInit] = fetchSpy.mock.calls[1];
|
|
445
|
+
expect(verifyUrl).toBe("http://localhost:3000/api/register/verify");
|
|
446
|
+
expect(verifyInit.method).toBe("POST");
|
|
447
|
+
expect(JSON.parse(verifyInit.body)).toEqual(credential);
|
|
448
|
+
|
|
449
|
+
expect(el).dom.to.equalTag(
|
|
450
|
+
`<web-authn is-success options-url="/api/register/options" verify-url="/api/register/verify" start-method="register">
|
|
451
|
+
<form><input name="username" value="alice"></form>
|
|
452
|
+
</web-authn>`,
|
|
453
|
+
);
|
|
454
|
+
expect(el.provision).toEqual(
|
|
455
|
+
expect.objectContaining({ status: 200, body: { verified: true } }),
|
|
456
|
+
);
|
|
457
|
+
expect(successSpy.mock.calls[0][0].detail).toBe(el.provision);
|
|
458
|
+
expect(provisionSpy).toHaveBeenCalledTimes(1);
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it("sets is-error with the response payload when verify-url rejects the credential", async () => {
|
|
462
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
463
|
+
mockCeremony({ status: 401, body: { verified: false, error: "bad sig" } });
|
|
464
|
+
mockStartAuthentication.mockResolvedValue({ id: "cred-2" });
|
|
465
|
+
|
|
466
|
+
const el = fixture<HTMLWebAuthnElement>(
|
|
467
|
+
`<web-authn options-url="/api/auth/options" verify-url="/api/auth/verify" start-method="authenticate">
|
|
468
|
+
<form><input name="username" value="bob"></form>
|
|
469
|
+
</web-authn>`,
|
|
470
|
+
);
|
|
471
|
+
|
|
472
|
+
await waitForEvent(el, "web-authn-error", () => {
|
|
473
|
+
el.querySelector("form")!.dispatchEvent(
|
|
474
|
+
new Event("submit", { cancelable: true, bubbles: true }),
|
|
475
|
+
);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
expect(el.hasAttribute("is-error")).toBe(true);
|
|
479
|
+
expect(el.hasAttribute("is-success")).toBe(false);
|
|
480
|
+
expect(el.hasAttribute("is-loading")).toBe(false);
|
|
481
|
+
expect(el.provision).toEqual(
|
|
482
|
+
expect.objectContaining({
|
|
483
|
+
status: 401,
|
|
484
|
+
body: { verified: false, error: "bad sig" },
|
|
485
|
+
}),
|
|
486
|
+
);
|
|
487
|
+
});
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
describe("ceremony errors", () => {
|
|
491
|
+
it("reports the Error message when the ceremony rejects with an Error", async () => {
|
|
492
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
493
|
+
mockOptionsEndpoint();
|
|
494
|
+
mockStartRegistration.mockRejectedValue(new Error("User canceled"));
|
|
495
|
+
|
|
496
|
+
const el = fixture<HTMLWebAuthnElement>(
|
|
497
|
+
`<web-authn options-url="/api/options" verify-url="/api/verify" start-method="register">
|
|
498
|
+
<form><input name="username" value="alice"></form>
|
|
499
|
+
</web-authn>`,
|
|
500
|
+
);
|
|
501
|
+
const spy = vi.fn();
|
|
502
|
+
el.addEventListener("web-authn-error", spy);
|
|
503
|
+
|
|
504
|
+
await waitForEvent(el, "web-authn-error", () => {
|
|
505
|
+
el.querySelector("form")!.dispatchEvent(
|
|
506
|
+
new Event("submit", { cancelable: true, bubbles: true }),
|
|
507
|
+
);
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
expect(spy.mock.calls[0][0].detail).toEqual({ message: "User canceled" });
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
it("reports a generic message when the ceremony rejects with a non-Error", async () => {
|
|
514
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
515
|
+
mockOptionsEndpoint();
|
|
516
|
+
mockStartAuthentication.mockRejectedValue({ name: "NotAllowedError" });
|
|
517
|
+
|
|
518
|
+
const el = fixture<HTMLWebAuthnElement>(
|
|
519
|
+
`<web-authn options-url="/api/options" verify-url="/api/verify" start-method="authenticate">
|
|
520
|
+
<form><input name="username" value="alice"></form>
|
|
521
|
+
</web-authn>`,
|
|
522
|
+
);
|
|
523
|
+
const spy = vi.fn();
|
|
524
|
+
el.addEventListener("web-authn-error", spy);
|
|
525
|
+
|
|
526
|
+
await waitForEvent(el, "web-authn-error", () => {
|
|
527
|
+
el.querySelector("form")!.dispatchEvent(
|
|
528
|
+
new Event("submit", { cancelable: true, bubbles: true }),
|
|
529
|
+
);
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
expect(spy.mock.calls[0][0].detail).toEqual({
|
|
533
|
+
message: "WebAuthn operation failed",
|
|
534
|
+
});
|
|
535
|
+
expect(el.hasAttribute("is-success")).toBe(false);
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
it("reports the status text when the options fetch is not ok", async () => {
|
|
539
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
540
|
+
vi.spyOn(globalThis, "fetch").mockImplementation(() =>
|
|
541
|
+
Promise.resolve(
|
|
542
|
+
new Response(null, { status: 403, statusText: "Forbidden" }),
|
|
543
|
+
),
|
|
544
|
+
);
|
|
545
|
+
|
|
546
|
+
const el = fixture<HTMLWebAuthnElement>(
|
|
547
|
+
`<web-authn options-url="/api/options" verify-url="/api/verify" start-method="register">
|
|
548
|
+
<form><input name="username" value="alice"></form>
|
|
549
|
+
</web-authn>`,
|
|
550
|
+
);
|
|
551
|
+
const spy = vi.fn();
|
|
552
|
+
el.addEventListener("web-authn-error", spy);
|
|
553
|
+
|
|
554
|
+
await waitForEvent(el, "web-authn-error", () => {
|
|
555
|
+
el.querySelector("form")!.dispatchEvent(
|
|
556
|
+
new Event("submit", { cancelable: true, bubbles: true }),
|
|
557
|
+
);
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
expect(spy.mock.calls[0][0].detail.message).toBe(
|
|
561
|
+
"Failed to fetch options: Forbidden",
|
|
562
|
+
);
|
|
563
|
+
expect(mockStartRegistration).not.toHaveBeenCalled();
|
|
564
|
+
});
|
|
565
|
+
});
|
|
566
|
+
});
|