1import { assert, describe, it } from "@effect/vitest";
2import { afterEach, vi } from "vite-plus/test";
3
4import { PASSKEY_CREATE_CHANNEL, PASSKEY_GET_CHANNEL } from "./GuestProtocol.ts";
5import { installPasskeyBridge } from "./PasskeyBridge.ts";
6
7const base64Url = (bytes: ReadonlyArray<number>) => Buffer.from(bytes).toString("base64url");
8const bytesOf = (buffer: ArrayBuffer | null) => (buffer ? [...new Uint8Array(buffer)] : null);
9
10const assertionData = {
11 credentialId: base64Url([1, 2, 3]),
12 clientDataJSON: base64Url([4]),
13 authenticatorData: base64Url([5]),
14 signature: base64Url([6, 7]),
15 userHandle: base64Url([8]),
16 extensions: { prf: { results: { first: base64Url([9]) } } },
17};
18
19const creationOptions: PublicKeyCredentialCreationOptions = {
20 challenge: new Uint8Array([1]),
21 rp: { name: "Example" },
22 user: { id: new Uint8Array([2]), name: "alice", displayName: "Alice" },
23 pubKeyCredParams: [{ type: "public-key", alg: -7 }],
24};
25
26const illegalInvocation = (): never => {
27 throw new TypeError("Illegal invocation");
28};
29
30const install = (respond: (channel: string) => unknown) => {
31 const nativeCreate = vi.fn(async () => "native-create");
32 const nativeGet = vi.fn(async () => "native-get");
33 class FakeCredentialsContainer {
34 create() {
35 return nativeCreate();
36 }
37 get() {
38 return nativeGet();
39 }
40 }
41 class FakePublicKeyCredential {
42 get id() {
43 return illegalInvocation();
44 }
45 get response() {
46 return illegalInvocation();
47 }
48 }
49 class FakeAttestationResponse {
50 get clientDataJSON() {
51 return illegalInvocation();
52 }
53 }
54 class FakeAssertionResponse {
55 get signature() {
56 return illegalInvocation();
57 }
58 }
59 vi.stubGlobal("CredentialsContainer", FakeCredentialsContainer);
60 vi.stubGlobal("PublicKeyCredential", FakePublicKeyCredential);
61 vi.stubGlobal("AuthenticatorAttestationResponse", FakeAttestationResponse);
62 vi.stubGlobal("AuthenticatorAssertionResponse", FakeAssertionResponse);
63
64 const invoke = vi.fn(async (channel: string, _publicKey: unknown) => respond(channel));
65 installPasskeyBridge(invoke);
66 return {
67 invoke,
68 nativeCreate,
69 nativeGet,
70 credentials: new FakeCredentialsContainer() as unknown as CredentialsContainer,
71 FakePublicKeyCredential,
72 FakeAttestationResponse,
73 FakeAssertionResponse,
74 };
75};
76
77describe("installPasskeyBridge", () => {
78 afterEach(() => {
79 vi.unstubAllGlobals();
80 });
81
82 it("serves passkey sign-in through the main process with a credential pages can verify", async () => {
83 const { invoke, credentials, FakePublicKeyCredential, FakeAssertionResponse } = install(() => ({
84 success: true,
85 data: assertionData,
86 }));
87 const challenge = new Uint8Array([0, 1, 2, 3]).subarray(1);
88
89 const credential = (await credentials.get({
90 publicKey: {
91 challenge,
92 rpId: "example.com",
93 allowCredentials: [{ type: "public-key", id: new Uint8Array([1, 2, 3]) }],
94 extensions: { onHint: () => {} } as AuthenticationExtensionsClientInputs,
95 },
96 })) as PublicKeyCredential;
97
98 assert.deepStrictEqual(invoke.mock.calls[0], [
99 PASSKEY_GET_CHANNEL,
100 {
101 challenge: new Uint8Array([1, 2, 3]),
102 rpId: "example.com",
103 allowCredentials: [{ type: "public-key", id: new Uint8Array([1, 2, 3]) }],
104 extensions: {},
105 },
106 ]);
107 const response = credential.response as AuthenticatorAssertionResponse;
108 assert.instanceOf(credential, FakePublicKeyCredential);
109 assert.instanceOf(response, FakeAssertionResponse);
110 assert.strictEqual(credential.id, assertionData.credentialId);
111 assert.deepStrictEqual(bytesOf(credential.rawId), [1, 2, 3]);
112 assert.deepStrictEqual(bytesOf(response.signature), [6, 7]);
113 assert.deepStrictEqual(bytesOf(response.userHandle), [8]);
114 assert.deepStrictEqual(
115 bytesOf(credential.getClientExtensionResults().prf?.results?.first as ArrayBuffer),
116 [9],
117 );
118 assert.deepStrictEqual(credential.toJSON(), {
119 id: assertionData.credentialId,
120 rawId: assertionData.credentialId,
121 type: "public-key",
122 response: {
123 clientDataJSON: assertionData.clientDataJSON,
124 authenticatorData: assertionData.authenticatorData,
125 signature: assertionData.signature,
126 userHandle: assertionData.userHandle,
127 },
128 clientExtensionResults: { prf: { results: { first: base64Url([9]) } } },
129 });
130 });
131
132 it("rejects with the WebAuthn error the ceremony ended with", async () => {
133 const { credentials } = install((channel) => ({
134 success: false,
135 error: channel === PASSKEY_CREATE_CHANNEL ? "InvalidStateError" : "TypeError",
136 }));
137 const created = await credentials
138 .create({ publicKey: creationOptions })
139 .catch((error: unknown) => error);
140 assert.instanceOf(created, DOMException);
141 assert.strictEqual((created as DOMException).name, "InvalidStateError");
142 const fetched = await credentials
143 .get({ publicKey: { challenge: new Uint8Array([1]) } })
144 .catch((error: unknown) => error);
145 assert.instanceOf(fetched, TypeError);
146 });
147
148 it("rejects as soon as the page aborts, without waiting on the system sheet", async () => {
149 const { credentials } = install(() => new Promise(() => {}));
150 const controller = new AbortController();
151
152 const pending = credentials
153 .get({ publicKey: { challenge: new Uint8Array([1]) }, signal: controller.signal })
154 .catch((error: unknown) => error);
155 controller.abort();
156
157 const error = await pending;
158 assert.instanceOf(error, DOMException);
159 assert.strictEqual((error as DOMException).name, "AbortError");
160 });
161});