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 attestationData = {
20 credentialId: base64Url([1, 2, 3]),
21 clientDataJSON: base64Url([4]),
22 attestationObject: base64Url([10]),
23 authData: base64Url([11]),
24 publicKey: base64Url([12]),
25 publicKeyAlgorithm: -7,
26 transports: [],
27 extensions: { credProps: { rk: true } },
28};
29
30const creationOptions: PublicKeyCredentialCreationOptions = {
31 challenge: new Uint8Array([1]),
32 rp: { name: "Example" },
33 user: { id: new Uint8Array([2]), name: "alice", displayName: "Alice" },
34 pubKeyCredParams: [{ type: "public-key", alg: -7 }],
35};
36
37const illegalInvocation = (): never => {
38 throw new TypeError("Illegal invocation");
39};
40
41/**
42 * Installs the bridge over fresh stand-ins for the page's DOM classes. Like
43 * Chromium's, their getters only work on objects the browser created itself.
44 */
45const install = (respond: (channel: string) => unknown) => {
46 const nativeCreate = vi.fn(async () => "native-create");
47 const nativeGet = vi.fn(async () => "native-get");
48 class FakeCredentialsContainer {
49 create() {
50 return nativeCreate();
51 }
52 get() {
53 return nativeGet();
54 }
55 }
56 class FakePublicKeyCredential {
57 get id() {
58 return illegalInvocation();
59 }
60 get response() {
61 return illegalInvocation();
62 }
63 }
64 class FakeAttestationResponse {
65 get clientDataJSON() {
66 return illegalInvocation();
67 }
68 }
69 class FakeAssertionResponse {
70 get signature() {
71 return illegalInvocation();
72 }
73 }
74 vi.stubGlobal("CredentialsContainer", FakeCredentialsContainer);
75 vi.stubGlobal("PublicKeyCredential", FakePublicKeyCredential);
76 vi.stubGlobal("AuthenticatorAttestationResponse", FakeAttestationResponse);
77 vi.stubGlobal("AuthenticatorAssertionResponse", FakeAssertionResponse);
78
79 const invoke = vi.fn(async (channel: string, _publicKey: unknown) => respond(channel));
80 installPasskeyBridge(invoke);
81 return {
82 invoke,
83 nativeCreate,
84 nativeGet,
85 credentials: new FakeCredentialsContainer() as unknown as CredentialsContainer,
86 FakePublicKeyCredential,
87 FakeAttestationResponse,
88 FakeAssertionResponse,
89 };
90};
91
92describe("installPasskeyBridge", () => {
93 afterEach(() => {
94 vi.unstubAllGlobals();
95 });
96
97 it("serves passkey sign-in through the main process with a credential pages can verify", async () => {
98 const { invoke, credentials, FakePublicKeyCredential, FakeAssertionResponse } = install(() => ({
99 success: true,
100 data: assertionData,
101 }));
102 const challenge = new Uint8Array([0, 1, 2, 3]).subarray(1);
103
104 const credential = (await credentials.get({
105 publicKey: {
106 challenge,
107 rpId: "example.com",
108 allowCredentials: [{ type: "public-key", id: new Uint8Array([1, 2, 3]) }],
109 // Unknown members the page adds must not break IPC cloning.
110 extensions: { onHint: () => {} } as AuthenticationExtensionsClientInputs,
111 },
112 })) as PublicKeyCredential;
113
114 assert.deepStrictEqual(invoke.mock.calls[0], [
115 PASSKEY_GET_CHANNEL,
116 {
117 challenge: new Uint8Array([1, 2, 3]),
118 rpId: "example.com",
119 allowCredentials: [{ type: "public-key", id: new Uint8Array([1, 2, 3]) }],
120 extensions: {},
121 },
122 ]);
123 const response = credential.response as AuthenticatorAssertionResponse;
124 assert.instanceOf(credential, FakePublicKeyCredential);
125 assert.instanceOf(response, FakeAssertionResponse);
126 assert.strictEqual(credential.id, assertionData.credentialId);
127 assert.deepStrictEqual(bytesOf(credential.rawId), [1, 2, 3]);
128 assert.deepStrictEqual(bytesOf(response.signature), [6, 7]);
129 assert.deepStrictEqual(bytesOf(response.userHandle), [8]);
130 assert.deepStrictEqual(
131 bytesOf(credential.getClientExtensionResults().prf?.results?.first as ArrayBuffer),
132 [9],
133 );
134 assert.deepStrictEqual(credential.toJSON(), {
135 id: assertionData.credentialId,
136 rawId: assertionData.credentialId,
137 type: "public-key",
138 response: {
139 clientDataJSON: assertionData.clientDataJSON,
140 authenticatorData: assertionData.authenticatorData,
141 signature: assertionData.signature,
142 userHandle: assertionData.userHandle,
143 },
144 clientExtensionResults: { prf: { results: { first: base64Url([9]) } } },
145 });
146 });
147
148 it("returns registrations with their attestation and public key", async () => {
149 const { invoke, credentials, FakeAttestationResponse } = install(() => ({
150 success: true,
151 data: attestationData,
152 }));
153
154 const credential = (await credentials.create({
155 publicKey: creationOptions,
156 })) as PublicKeyCredential;
157
158 assert.strictEqual(invoke.mock.calls[0]?.[0], PASSKEY_CREATE_CHANNEL);
159 const response = credential.response as AuthenticatorAttestationResponse;
160 assert.instanceOf(response, FakeAttestationResponse);
161 assert.isNull(credential.authenticatorAttachment);
162 assert.deepStrictEqual(bytesOf(response.attestationObject), [10]);
163 assert.deepStrictEqual(bytesOf(response.getAuthenticatorData()), [11]);
164 assert.deepStrictEqual(bytesOf(response.getPublicKey()), [12]);
165 assert.strictEqual(response.getPublicKeyAlgorithm(), -7);
166 assert.deepStrictEqual(credential.getClientExtensionResults(), { credProps: { rk: true } });
167 assert.deepStrictEqual(credential.toJSON(), {
168 id: attestationData.credentialId,
169 rawId: attestationData.credentialId,
170 type: "public-key",
171 response: {
172 clientDataJSON: attestationData.clientDataJSON,
173 attestationObject: attestationData.attestationObject,
174 authenticatorData: attestationData.authData,
175 transports: [],
176 publicKey: attestationData.publicKey,
177 publicKeyAlgorithm: -7,
178 },
179 clientExtensionResults: { credProps: { rk: true } },
180 });
181 });
182
183 it("leaves autofill and non-passkey requests to Chromium", async () => {
184 const { invoke, credentials, nativeCreate, nativeGet } = install(() => ({
185 success: true,
186 data: assertionData,
187 }));
188
189 await credentials.get({
190 mediation: "conditional",
191 publicKey: { challenge: new Uint8Array([1]) },
192 });
193 await credentials.get({ password: true } as CredentialRequestOptions);
194 // Conditional create upgrades a password sign-in silently; no modal sheet.
195 const upgrade = { mediation: "conditional", publicKey: creationOptions } as const;
196 await credentials.create(upgrade);
197 await credentials.create({ password: {} } as CredentialCreationOptions);
198 assert.strictEqual(invoke.mock.calls.length, 0);
199 assert.strictEqual(nativeGet.mock.calls.length, 2);
200 assert.strictEqual(nativeCreate.mock.calls.length, 2);
201 assert.isFalse(await PublicKeyCredential.isConditionalMediationAvailable());
202 assert.isTrue(await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable());
203 });
204
205 it("rejects with the WebAuthn error the ceremony ended with", async () => {
206 const { credentials } = install((channel) => ({
207 success: false,
208 error: channel === PASSKEY_CREATE_CHANNEL ? "InvalidStateError" : "TypeError",
209 }));
210 const created = await credentials
211 .create({ publicKey: creationOptions })
212 .catch((error: unknown) => error);
213 assert.instanceOf(created, DOMException);
214 assert.strictEqual((created as DOMException).name, "InvalidStateError");
215 const fetched = await credentials
216 .get({ publicKey: { challenge: new Uint8Array([1]) } })
217 .catch((error: unknown) => error);
218 assert.instanceOf(fetched, TypeError);
219 });
220
221 it("refuses when the main process has no handler for the page", async () => {
222 const { credentials } = install(() => {
223 throw new Error("Error invoking remote method 'preview:passkey-get': No handler registered");
224 });
225
226 const error = await credentials
227 .get({ publicKey: { challenge: new Uint8Array([1]) } })
228 .catch((caught: unknown) => caught);
229 assert.instanceOf(error, DOMException);
230 assert.strictEqual((error as DOMException).name, "NotAllowedError");
231 });
232
233 it("rejects as soon as the page aborts, without waiting on the system sheet", async () => {
234 const { credentials } = install(() => new Promise(() => {}));
235 const controller = new AbortController();
236
237 const pending = credentials
238 .get({ publicKey: { challenge: new Uint8Array([1]) }, signal: controller.signal })
239 .catch((error: unknown) => error);
240 controller.abort();
241
242 const error = await pending;
243 assert.instanceOf(error, DOMException);
244 assert.strictEqual((error as DOMException).name, "AbortError");
245 });
246});