| 1 | import { assert, describe, it } from "@effect/vitest"; |
| 2 | import { afterEach, vi } from "vite-plus/test"; |
| 3 | |
| 4 | import { PASSKEY_CREATE_CHANNEL, PASSKEY_GET_CHANNEL } from "./GuestProtocol.ts"; |
| 5 | import { installPasskeyBridge } from "./PasskeyBridge.ts"; |
| 6 | |
| 7 | const base64Url = (bytes: ReadonlyArray<number>) => Buffer.from(bytes).toString("base64url"); |
| 8 | const bytesOf = (buffer: ArrayBuffer | null) => (buffer ? [...new Uint8Array(buffer)] : null); |
| 9 | |
| 10 | const 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 | |
| 19 | const 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 | |
| 30 | const 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 | |
| 37 | const 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 | */ |
| 45 | const 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 | |
| 92 | describe("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 | }); |