Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Helper to find the RP ID given users' devices and current origin #2729

Merged
merged 6 commits into from
Dec 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 140 additions & 0 deletions src/frontend/src/utils/findWebAuthnRpId.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { DeviceData } from "$generated/internet_identity_types";
import { findWebAuthnRpId } from "./findWebAuthnRpId";

describe("findWebAuthnRpId", () => {
const mockDeviceData = (origin: [] | [string]): DeviceData => ({
origin,
alias: "test-device",
metadata: [],
protection: { protected: null },
pubkey: [],
key_type: { platform: null },
purpose: { authentication: null },
credential_id: [],
});

beforeEach(() => {
vi.spyOn(console, "error").mockImplementation(() => {});
});

test("returns undefined if a device is registered for the current domain", () => {
const devices: DeviceData[] = [
mockDeviceData(["https://identity.ic0.app"]),
mockDeviceData(["https://identity.internetcomputer.org"]),
mockDeviceData(["https://identity.icp0.io"]),
];
const currentUrl = "https://identity.ic0.app";

expect(findWebAuthnRpId(currentUrl, devices)).toBeUndefined();
});

test("returns undefined for devices with default domain when the current domain matches", () => {
const devices: DeviceData[] = [
mockDeviceData([]), // Empty origin defaults to defaultDomain `https://identity.ic0.app`
mockDeviceData(["https://identity.internetcomputer.org"]),
mockDeviceData(["https://identity.icp0.io"]),
];
const currentUrl = "https://identity.ic0.app";

expect(findWebAuthnRpId(currentUrl, devices)).toBeUndefined();
});

test("returns undefined if a device is registered for the current domain", () => {
const devices: DeviceData[] = [
mockDeviceData(["https://beta.identity.ic0.app"]),
mockDeviceData(["https://beta.identity.internetcomputer.org"]),
];
const currentUrl = "https://beta.identity.ic0.app";

expect(findWebAuthnRpId(currentUrl, devices)).toBeUndefined();
});

test("returns undefined if a device is registered for the current domain", () => {
const devices: DeviceData[] = [
mockDeviceData(["https://identity.ic0.app"]),
mockDeviceData(["https://identity.internetcomputer.org"]),
mockDeviceData(["https://identity.icp0.io"]),
];
const currentUrl = "https://identity.internetcomputer.org";

expect(findWebAuthnRpId(currentUrl, devices)).toBeUndefined();
});

test("returns the second default preferred domain if no device is registered for the current domain", () => {
const devices: DeviceData[] = [
mockDeviceData(["https://identity.internetcomputer.org"]),
mockDeviceData(["https://identity.icp0.io"]),
];
const currentUrl = "https://identity.ic0.app";

expect(findWebAuthnRpId(currentUrl, devices)).toBe(
"https://identity.internetcomputer.org"
);
});

test("returns the first default preferred domain if no device is registered for the current domain", () => {
const devices: DeviceData[] = [
mockDeviceData(["https://identity.ic0.app"]),
mockDeviceData(["https://identity.icp0.io"]),
];
const currentUrl = "https://identity.internetcomputer.org";

expect(findWebAuthnRpId(currentUrl, devices)).toBe(
"https://identity.ic0.app"
);
});

test("returns the least preferred domain if devices are only on that domain", () => {
const devices: DeviceData[] = [
mockDeviceData(["https://identity.icp0.io"]),
];
const currentUrl = "https://identity.ic0.app";

expect(findWebAuthnRpId(currentUrl, devices)).toBe(
"https://identity.icp0.io"
);
});

test("uses preferred domains when provided", () => {
const preferredDomains = ["ic0.app", "icp0.io", "internetcomputer.org"];

const devices: DeviceData[] = [
mockDeviceData(["https://identity.internetcomputer.org"]),
mockDeviceData(["https://identity.icp0.io"]),
];
const currentUrl = "https://identity.ic0.app";

expect(findWebAuthnRpId(currentUrl, devices, preferredDomains)).toBe(
"https://identity.icp0.io"
);
});

test("throws an error if the current domain is invalid", () => {
const devices: DeviceData[] = [
mockDeviceData(["https://identity.ic0.app"]),
];
const currentUrl = "not-a-valid-url";

expect(() => findWebAuthnRpId(currentUrl, devices)).toThrowError(
"Invalid URL: not-a-valid-url"
);
});

test("throws an error if no devices are registered for the current or preferred domains", () => {
const devices: DeviceData[] = [mockDeviceData(["https://otherdomain.com"])];
const currentUrl = "https://identity.ic0.app";

expect(() => findWebAuthnRpId(currentUrl, devices)).toThrowError(
"Not possible. Devices must be registered for at least one of the following domains: ic0.app, internetcomputer.org, icp0.io"
);
});

test("throws an error if there are no registered devices", () => {
const devices: DeviceData[] = [];
const currentUrl = "https://identity.ic0.app";

expect(() => findWebAuthnRpId(currentUrl, devices)).toThrowError(
"Not possible. Every registered user has at least one device."
);
});
});
102 changes: 102 additions & 0 deletions src/frontend/src/utils/findWebAuthnRpId.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { DeviceData } from "$generated/internet_identity_types";

const DEFAULT_DOMAIN = "https://identity.ic0.app";

/**
* Helper to extract the top and secondary level domain from a URL.
*
* Example: "https://identity.ic0.app" -> "ic0.app"
*
* @param url {string} The URL to extract the domain from.
* @returns {string} The top and secondary level domain.
*
* @throws {Error} If the URL is invalid.
* @throws {Error} If the URL does not contain a top and secondary level domain.
*/
const getTopAndSecondaryLevelDomain = (url: string): string => {
const parts = new URL(url).hostname.split(".");

if (parts.length < 2) {
throw new Error("Invalid URL: Unable to extract domain.");
}

return parts.slice(-2).join(".");
};

/**
* Helper to count devices for a domain, defaulting to "ic0.app" if origin is empty
*
* @param devices - The list of devices registered for the user.
* @param domain - The domain to check for devices. It must be a top and secondary level domain e.g. "ic0.app"
* We need this to support the beta domains with the same functions: beta.identity.ic0.app, beta.identity.internetcomputer.org
* @returns {DeviceData[]} The list of devices registered for the domain.
*/
const getDevicesForDomain = (
devices: DeviceData[],
domain: string
): DeviceData[] =>
devices.filter((d) => {
if (d.origin.length === 0)
return domain === getTopAndSecondaryLevelDomain(DEFAULT_DOMAIN);
return d.origin.some((o) => getTopAndSecondaryLevelDomain(o) === domain);
});

/**
* Returns the domain to use as the RP ID for WebAuthn registration.
*
* The algorithm is as follows:
* 1. If there is a device registered for the current domain, return undefined.
* 2. If there is no device registered for the current domain, check if there is a device registered for one of the preferred domains.
* If there is, return the first preferred domain that has a device registered.
* 3. If there is no device registered for the current domain and none of the preferred domains.
* Raise an error because the devices should be registered in one of the preferred domains.
*
* @param currentUrl - The current URL of the page.
* @param devices - The list of devices registered for the user.
* @param preferredDomains - Optional list of domains in order or preference to use as the RP ID.
* @returns {string | undefined} The RP ID to use for WebAuthn registration.
* `undefined` when the RP ID is the same as the current domain and is not needed.
*
* @throws {Error} If devices are not registered for any of the preferred domains.
* @throws {Error} If no devices exist or the current domain is invalid.
* @throws {Error} If the current domain is invalid.
*/
export const findWebAuthnRpId = (
currentUrl: string,
devices: DeviceData[],
preferredDomains: string[] = ["ic0.app", "internetcomputer.org", "icp0.io"]
): string | undefined => {
const currentDomain = getTopAndSecondaryLevelDomain(currentUrl);

if (devices.length === 0) {
throw new Error(
"Not possible. Every registered user has at least one device."
);
}

const getFirstDomain = (devices: DeviceData[]): string => {
if (devices[0] === undefined) {
throw new Error(
"Not possible. Call this function only if devices exist."
);
}
return devices[0].origin[0] ?? DEFAULT_DOMAIN;
};

// Try current domain first if devices exist
if (getDevicesForDomain(devices, currentDomain).length > 0) {
return undefined;
}

// Check based on the order of preferred domains if there is no device with the current domain.
for (const domain of preferredDomains) {
const devicesForDomain = getDevicesForDomain(devices, domain);
if (devicesForDomain.length > 0) {
return getFirstDomain(devicesForDomain);
}
}

throw new Error(
"Not possible. Devices must be registered for at least one of the following domains: ic0.app, internetcomputer.org, icp0.io"
);
};
Loading