diff --git a/src/frontend/src/utils/findWebAuthnRpId.test.ts b/src/frontend/src/utils/findWebAuthnRpId.test.ts index 6cc8d5cdec..cb7c486d14 100644 --- a/src/frontend/src/utils/findWebAuthnRpId.test.ts +++ b/src/frontend/src/utils/findWebAuthnRpId.test.ts @@ -2,6 +2,7 @@ import { CredentialData } from "./credential-devices"; import { BETA_DOMAINS, PROD_DOMAINS, + excludeCredentialsFromOrigins, findWebAuthnRpId, } from "./findWebAuthnRpId"; @@ -165,3 +166,108 @@ describe("findWebAuthnRpId", () => { ); }); }); + +describe("excludeCredentialsFromOrigins", () => { + const mockDeviceData = (origin?: string): CredentialData => ({ + origin, + credentialId: new ArrayBuffer(1), + pubkey: new ArrayBuffer(1), + }); + + test("excludes credentials from specified origins", () => { + const credentials = [ + mockDeviceData("https://identity.ic0.app"), + mockDeviceData("https://identity.internetcomputer.org"), + mockDeviceData("https://identity.icp0.io"), + ]; + const originsToExclude = new Set(["https://identity.ic0.app"]); + const currentOrigin = "https://identity.internetcomputer.org"; + + const result = excludeCredentialsFromOrigins( + credentials, + originsToExclude, + currentOrigin + ); + + expect(result).toHaveLength(2); + expect(result).toEqual([ + mockDeviceData("https://identity.internetcomputer.org"), + mockDeviceData("https://identity.icp0.io"), + ]); + }); + + test("treats undefined credential origins as DEFAULT_DOMAIN", () => { + const credentials = [ + mockDeviceData(undefined), // Should be treated as DEFAULT_DOMAIN + mockDeviceData("https://identity.internetcomputer.org"), + ]; + const originsToExclude = new Set(["https://identity.ic0.app"]); // Should match DEFAULT_DOMAIN + const currentOrigin = "https://identity.internetcomputer.org"; + + const result = excludeCredentialsFromOrigins( + credentials, + originsToExclude, + currentOrigin + ); + + expect(result).toHaveLength(1); + expect(result).toEqual([ + mockDeviceData("https://identity.internetcomputer.org"), + ]); + }); + + test("treats undefined origins in exclusion set as currentOrigin", () => { + const credentials = [ + mockDeviceData("https://identity.ic0.app"), + mockDeviceData("https://identity.internetcomputer.org"), + ]; + const originsToExclude = new Set([undefined]); // Should be treated as currentOrigin + const currentOrigin = "https://identity.internetcomputer.org"; + + const result = excludeCredentialsFromOrigins( + credentials, + originsToExclude, + currentOrigin + ); + + expect(result).toHaveLength(1); + expect(result).toEqual([mockDeviceData("https://identity.ic0.app")]); + }); + + test("returns empty array when all credentials are excluded", () => { + const credentials = [ + mockDeviceData("https://identity.ic0.app"), + mockDeviceData("https://identity.internetcomputer.org"), + ]; + const originsToExclude = new Set([ + "https://identity.ic0.app", + "https://identity.internetcomputer.org", + ]); + const currentOrigin = "https://identity.ic0.app"; + + const result = excludeCredentialsFromOrigins( + credentials, + originsToExclude, + currentOrigin + ); + + expect(result).toHaveLength(0); + }); + + test("returns all credentials when no origins to exclude", () => { + const credentials = [ + mockDeviceData("https://identity.ic0.app"), + mockDeviceData("https://identity.internetcomputer.org"), + ]; + const originsToExclude = new Set(); + const currentOrigin = "https://identity.ic0.app"; + + const result = excludeCredentialsFromOrigins( + credentials, + originsToExclude, + currentOrigin + ); + + expect(result).toEqual(credentials); + }); +}); diff --git a/src/frontend/src/utils/findWebAuthnRpId.ts b/src/frontend/src/utils/findWebAuthnRpId.ts index a3163d9706..c23a77897e 100644 --- a/src/frontend/src/utils/findWebAuthnRpId.ts +++ b/src/frontend/src/utils/findWebAuthnRpId.ts @@ -30,6 +30,43 @@ export const relatedDomains = (): string[] => { return []; }; +export const hasCredentialsFromMultipleOrigins = ( + credentials: CredentialData[] +): boolean => + new Set(credentials.map(({ origin }) => origin ?? DEFAULT_DOMAIN)).size > 1; + +/** + * Filters out credentials from specific origins. + * + * This function takes a list of credentials and removes any that match the provided origins. + * If a credential has no origin (undefined), it is treated as if it had the `DEFAULT_DOMAIN`. + * Two origins match if they have the same hostname (domain). + * + * @param credentials - List of credential devices to filter + * @param origins - Set of origins to exclude (undefined values are treated as `currentOrigin`) + * @param currentOrigin - The current origin to use when comparing against undefined origins + * @returns Filtered list of credentials, excluding those from the specified origins + */ +export const excludeCredentialsFromOrigins = ( + credentials: CredentialData[], + origins: Set, + currentOrigin: string +): CredentialData[] => { + if (origins.size === 0) { + return credentials; + } + // Change `undefined` to the current origin. + const originsToExclude = Array.from(origins).map( + (origin) => origin ?? currentOrigin + ); + return credentials.filter( + (credential) => + originsToExclude.filter((originToExclude) => + sameDomain(credential.origin ?? DEFAULT_DOMAIN, originToExclude) + ).length === 0 + ); +}; + const sameDomain = (url1: string, url2: string): boolean => new URL(url1).hostname === new URL(url2).hostname; diff --git a/src/frontend/src/utils/iiConnection.test.ts b/src/frontend/src/utils/iiConnection.test.ts index f1a541d3d9..4addc3c9f5 100644 --- a/src/frontend/src/utils/iiConnection.test.ts +++ b/src/frontend/src/utils/iiConnection.test.ts @@ -14,16 +14,17 @@ import { CredentialData, convertToCredentialData } from "./credential-devices"; import { AuthenticatedConnection, Connection } from "./iiConnection"; import { MultiWebAuthnIdentity } from "./multiWebAuthnIdentity"; -const mockDevice: DeviceData = { +const createMockDevice = (origin?: string): DeviceData => ({ alias: "mockDevice", metadata: [], - origin: [], + origin: origin !== undefined ? [origin] : [], protection: { protected: null }, pubkey: new Uint8Array(), key_type: { platform: null }, purpose: { authentication: null }, credential_id: [], -}; +}); +const mockDevice = createMockDevice(); const mockDelegationIdentity = { getDelegation() { @@ -57,11 +58,13 @@ const mockActor = { lookup: vi.fn().mockResolvedValue([mockDevice]), } as unknown as ActorSubclass<_SERVICE>; +const currentOrigin = "https://identity.internetcomputer.org"; + beforeEach(() => { infoResponse = undefined; vi.clearAllMocks(); vi.stubGlobal("location", { - origin: "https://identity.internetcomputer.org", + origin: currentOrigin, }); DOMAIN_COMPATIBILITY.reset(); }); @@ -112,7 +115,9 @@ test("commits changes on identity metadata", async () => { }); describe("Connection.login", () => { + let failSign = false; beforeEach(() => { + failSign = false; vi.spyOn(MultiWebAuthnIdentity, "fromCredentials").mockImplementation( () => { const mockIdentity = { @@ -133,6 +138,9 @@ describe("Connection.login", () => { return new MockMultiWebAuthnIdentity(credentials, rpId); } override sign() { + if (failSign) { + throw new DOMException("Error test", "NotAllowedError"); + } this._actualIdentity = mockIdentity; return Promise.resolve(new ArrayBuffer(0) as Signature); } @@ -142,69 +150,227 @@ describe("Connection.login", () => { ); }); - it("login returns authenticated connection with expected rpID", async () => { - DOMAIN_COMPATIBILITY.set(true); - vi.stubGlobal("navigator", { - // Supports RoR - userAgent: - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15", + describe("domains compatibility flag enabled and browser support", () => { + beforeEach(() => { + DOMAIN_COMPATIBILITY.set(true); + vi.stubGlobal("navigator", { + // Supports RoR + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15", + }); }); - const connection = new Connection("aaaaa-aa", mockActor); - const loginResult = await connection.login(BigInt(12345)); + it("login returns authenticated connection with expected rpID", async () => { + const connection = new Connection("aaaaa-aa", mockActor); + + const loginResult = await connection.login(BigInt(12345)); + + expect(loginResult.kind).toBe("loginSuccess"); + if (loginResult.kind === "loginSuccess") { + expect(loginResult.connection).toBeInstanceOf(AuthenticatedConnection); + expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledTimes(1); + expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledWith( + [convertToCredentialData(mockDevice)], + "identity.ic0.app" + ); + } + }); + + it("connection exludes rpId when user cancels", async () => { + // This one would fail because it's not the device the user is using at the moment. + const currentOriginDevice: DeviceData = createMockDevice(currentOrigin); + const currentOriginCredentialData = + convertToCredentialData(currentOriginDevice); + const currentDevice: DeviceData = createMockDevice(); + const currentDeviceCredentialData = + convertToCredentialData(currentDevice); + const mockActor = { + identity_info: vi.fn().mockResolvedValue({ Ok: { metadata: [] } }), + lookup: vi.fn().mockResolvedValue([currentOriginDevice, currentDevice]), + } as unknown as ActorSubclass<_SERVICE>; + const connection = new Connection("aaaaa-aa", mockActor); + + failSign = true; + const firstLoginResult = await connection.login(BigInt(12345)); - expect(loginResult.kind).toBe("loginSuccess"); - if (loginResult.kind === "loginSuccess") { - expect(loginResult.connection).toBeInstanceOf(AuthenticatedConnection); + expect(firstLoginResult.kind).toBe("webAuthnFailed"); expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledTimes(1); expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledWith( - [convertToCredentialData(mockDevice)], - "identity.ic0.app" + expect.arrayContaining([ + currentOriginCredentialData, + currentDeviceCredentialData, + ]), + undefined ); - } - }); - it("login returns authenticated connection without rpID if flag is not enabled", async () => { - DOMAIN_COMPATIBILITY.set(false); - vi.stubGlobal("navigator", { - // Supports RoR - userAgent: - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15", + failSign = false; + const secondLoginResult = await connection.login(BigInt(12345)); + + expect(secondLoginResult.kind).toBe("loginSuccess"); + if (secondLoginResult.kind === "loginSuccess") { + expect(secondLoginResult.connection).toBeInstanceOf( + AuthenticatedConnection + ); + expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledTimes(2); + expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenNthCalledWith( + 2, + expect.arrayContaining([currentDeviceCredentialData]), + "identity.ic0.app" + ); + expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenNthCalledWith( + 2, + expect.not.arrayContaining([currentOriginCredentialData]), + "identity.ic0.app" + ); + } }); - const connection = new Connection("aaaaa-aa", mockActor); - const loginResult = await connection.login(BigInt(12345)); + it("connection doesn't exclude rpId if user has only one domain", async () => { + const currentOriginDevice: DeviceData = createMockDevice(currentOrigin); + const currentOriginCredentialData = + convertToCredentialData(currentOriginDevice); + const currentOriginDevice2: DeviceData = createMockDevice(currentOrigin); + const currentOriginCredentialData2 = + convertToCredentialData(currentOriginDevice2); + const mockActor = { + identity_info: vi.fn().mockResolvedValue({ Ok: { metadata: [] } }), + lookup: vi + .fn() + .mockResolvedValue([currentOriginDevice, currentOriginDevice2]), + } as unknown as ActorSubclass<_SERVICE>; + const connection = new Connection("aaaaa-aa", mockActor); + + failSign = true; + const firstLoginResult = await connection.login(BigInt(12345)); - expect(loginResult.kind).toBe("loginSuccess"); - if (loginResult.kind === "loginSuccess") { - expect(loginResult.connection).toBeInstanceOf(AuthenticatedConnection); + expect(firstLoginResult.kind).toBe("webAuthnFailed"); expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledTimes(1); expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledWith( - [convertToCredentialData(mockDevice)], + expect.arrayContaining([ + currentOriginCredentialData, + currentOriginCredentialData2, + ]), undefined ); - } + + failSign = false; + const secondLoginResult = await connection.login(BigInt(12345)); + + expect(secondLoginResult.kind).toBe("loginSuccess"); + if (secondLoginResult.kind === "loginSuccess") { + expect(secondLoginResult.connection).toBeInstanceOf( + AuthenticatedConnection + ); + expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledTimes(2); + expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenNthCalledWith( + 2, + expect.arrayContaining([ + currentOriginCredentialData, + currentOriginCredentialData2, + ]), + undefined + ); + } + }); }); - it("login returns authenticated connection without rpID if browser doesn't support it", async () => { - DOMAIN_COMPATIBILITY.set(true); - vi.stubGlobal("navigator", { - // Supports RoR - userAgent: - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:133.0) Gecko/20100101 Firefox/133.0", + describe("domains compatibility flag enabled and browser doesn't support", () => { + beforeEach(() => { + DOMAIN_COMPATIBILITY.set(true); + vi.stubGlobal("navigator", { + // Does NOT Supports RoR + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:133.0) Gecko/20100101 Firefox/133.0", + }); }); - const connection = new Connection("aaaaa-aa", mockActor); - const loginResult = await connection.login(BigInt(12345)); + it("login returns authenticated connection without rpID if browser doesn't support it", async () => { + const connection = new Connection("aaaaa-aa", mockActor); - expect(loginResult.kind).toBe("loginSuccess"); - if (loginResult.kind === "loginSuccess") { - expect(loginResult.connection).toBeInstanceOf(AuthenticatedConnection); + const loginResult = await connection.login(BigInt(12345)); + + expect(loginResult.kind).toBe("loginSuccess"); + if (loginResult.kind === "loginSuccess") { + expect(loginResult.connection).toBeInstanceOf(AuthenticatedConnection); + expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledTimes(1); + expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledWith( + [convertToCredentialData(mockDevice)], + undefined + ); + } + }); + }); + + describe("domains compatibility flag disabled", () => { + beforeEach(() => { + DOMAIN_COMPATIBILITY.set(false); + vi.stubGlobal("navigator", { + // Supports RoR + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15", + }); + }); + + it("login returns authenticated connection without rpID if flag is not enabled", async () => { + const connection = new Connection("aaaaa-aa", mockActor); + + const loginResult = await connection.login(BigInt(12345)); + + expect(loginResult.kind).toBe("loginSuccess"); + if (loginResult.kind === "loginSuccess") { + expect(loginResult.connection).toBeInstanceOf(AuthenticatedConnection); + expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledTimes(1); + expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledWith( + [convertToCredentialData(mockDevice)], + undefined + ); + } + }); + + it("connection does not exlud rpId when user cancels", async () => { + const currentOriginDevice: DeviceData = createMockDevice(currentOrigin); + const currentOriginCredentialData = + convertToCredentialData(currentOriginDevice); + const currentDevice: DeviceData = createMockDevice(); + const currentDeviceCredentialData = + convertToCredentialData(currentDevice); + const mockActor = { + identity_info: vi.fn().mockResolvedValue({ Ok: { metadata: [] } }), + lookup: vi.fn().mockResolvedValue([currentOriginDevice, currentDevice]), + } as unknown as ActorSubclass<_SERVICE>; + const connection = new Connection("aaaaa-aa", mockActor); + + failSign = true; + const firstLoginResult = await connection.login(BigInt(12345)); + + expect(firstLoginResult.kind).toBe("webAuthnFailed"); expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledTimes(1); expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledWith( - [convertToCredentialData(mockDevice)], + expect.arrayContaining([ + currentOriginCredentialData, + currentDeviceCredentialData, + ]), undefined ); - } + + failSign = false; + const secondLoginResult = await connection.login(BigInt(12345)); + + expect(secondLoginResult.kind).toBe("loginSuccess"); + if (secondLoginResult.kind === "loginSuccess") { + expect(secondLoginResult.connection).toBeInstanceOf( + AuthenticatedConnection + ); + expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledTimes(2); + expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenNthCalledWith( + 2, + expect.arrayContaining([ + currentDeviceCredentialData, + currentOriginCredentialData, + ]), + undefined + ); + } + }); }); }); diff --git a/src/frontend/src/utils/iiConnection.ts b/src/frontend/src/utils/iiConnection.ts index 877816319a..9813d4e257 100644 --- a/src/frontend/src/utils/iiConnection.ts +++ b/src/frontend/src/utils/iiConnection.ts @@ -51,7 +51,12 @@ import { import { Principal } from "@dfinity/principal"; import { isNullish, nonNullish } from "@dfinity/utils"; import { convertToCredentialData, CredentialData } from "./credential-devices"; -import { findWebAuthnRpId, relatedDomains } from "./findWebAuthnRpId"; +import { + excludeCredentialsFromOrigins, + findWebAuthnRpId, + hasCredentialsFromMultipleOrigins, + relatedDomains, +} from "./findWebAuthnRpId"; import { MultiWebAuthnIdentity } from "./multiWebAuthnIdentity"; import { isRecoveryDevice, RecoveryDevice } from "./recoveryDevice"; import { supportsWebauthRoR } from "./userAgent"; @@ -137,6 +142,12 @@ export interface IIWebAuthnIdentity extends SignIdentity { } export class Connection { + // The rpID is used to get the passkey from the browser's WebAuthn API. + // Using different rpIDs allows us to have compatibility across multiple domains. + // However, when one RP ID is used and the user cancels, it must be because the user is in a device + // registered in another domain. In this case, we must try the other rpID. + // Map> + private _cancelledRpIds: Map> = new Map(); public constructor( readonly canisterId: string, // Used for testing purposes @@ -376,16 +387,19 @@ export class Connection { userNumber: bigint, credentials: CredentialData[] ): Promise => { - // TODO: Filter out the credentials from the used rpIDs. - const rpId = + const cancelledRpIds = this._cancelledRpIds.get(userNumber) ?? new Set(); + const currentOrigin = window.location.origin; + const dynamicRPIdEnabled = DOMAIN_COMPATIBILITY.isEnabled() && - supportsWebauthRoR(window.navigator.userAgent) - ? findWebAuthnRpId( - window.location.origin, - credentials, - relatedDomains() - ) - : undefined; + supportsWebauthRoR(window.navigator.userAgent); + const filteredCredentials = excludeCredentialsFromOrigins( + credentials, + cancelledRpIds, + currentOrigin + ); + const rpId = dynamicRPIdEnabled + ? findWebAuthnRpId(currentOrigin, filteredCredentials, relatedDomains()) + : undefined; /* Recover the Identity (i.e. key pair) used when creating the anchor. * If the "DUMMY_AUTH" feature is set, we use a dummy identity, the same identity @@ -393,7 +407,7 @@ export class Connection { */ const identity = features.DUMMY_AUTH ? new DummyIdentity() - : MultiWebAuthnIdentity.fromCredentials(credentials, rpId); + : MultiWebAuthnIdentity.fromCredentials(filteredCredentials, rpId); let delegationIdentity: DelegationIdentity; // Here we expect a webauth exception if the user canceled the webauthn prompt (triggered by @@ -402,6 +416,17 @@ export class Connection { delegationIdentity = await this.requestFEDelegation(identity); } catch (e: unknown) { if (isWebAuthnCancel(e)) { + // We only want to cache cancelled rpids if there can be multiple rpids. + if ( + dynamicRPIdEnabled && + hasCredentialsFromMultipleOrigins(credentials) + ) { + if (this._cancelledRpIds.has(userNumber)) { + this._cancelledRpIds.get(userNumber)?.add(rpId); + } else { + this._cancelledRpIds.set(userNumber, new Set([rpId])); + } + } return { kind: "webAuthnFailed" }; }