Skip to content

Commit

Permalink
Change findWebAuthnRpId signature and return (#2744)
Browse files Browse the repository at this point in the history
  • Loading branch information
lmuntaner authored Dec 12, 2024
1 parent 680f8a0 commit fa5bf21
Show file tree
Hide file tree
Showing 2 changed files with 98 additions and 58 deletions.
78 changes: 54 additions & 24 deletions src/frontend/src/utils/findWebAuthnRpId.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { CredentialData } from "./credential-devices";
import { findWebAuthnRpId } from "./findWebAuthnRpId";
import {
BETA_DOMAINS,
PROD_DOMAINS,
findWebAuthnRpId,
} from "./findWebAuthnRpId";

describe("findWebAuthnRpId", () => {
const mockDeviceData = (origin?: string): CredentialData => ({
Expand All @@ -8,10 +12,6 @@ describe("findWebAuthnRpId", () => {
pubkey: new ArrayBuffer(1),
});

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

test("returns undefined if a device is registered for the current domain", () => {
const devices: CredentialData[] = [
mockDeviceData("https://identity.ic0.app"),
Expand All @@ -20,7 +20,7 @@ describe("findWebAuthnRpId", () => {
];
const currentUrl = "https://identity.ic0.app";

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

test("returns undefined for devices with default domain when the current domain matches", () => {
Expand All @@ -31,17 +31,17 @@ describe("findWebAuthnRpId", () => {
];
const currentUrl = "https://identity.ic0.app";

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

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

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

test("returns undefined if a device is registered for the current domain", () => {
Expand All @@ -52,7 +52,17 @@ describe("findWebAuthnRpId", () => {
];
const currentUrl = "https://identity.internetcomputer.org";

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

test("returns undefined if a device is registered for the current domain", () => {
const devices: CredentialData[] = [
mockDeviceData("https://beta.identity.ic0.app"),
mockDeviceData("https://fgte5-ciaaa-aaaad-aaatq-cai.ic0.app"),
];
const currentUrl = "https://fgte5-ciaaa-aaaad-aaatq-cai.ic0.app";

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

test("returns the second default preferred domain if no device is registered for the current domain", () => {
Expand All @@ -62,8 +72,19 @@ describe("findWebAuthnRpId", () => {
];
const currentUrl = "https://identity.ic0.app";

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

test("returns last beta if a device is registered for the current domain", () => {
const devices: CredentialData[] = [
mockDeviceData("https://fgte5-ciaaa-aaaad-aaatq-cai.ic0.app"),
];
const currentUrl = "https://beta.identity.ic0.app";

expect(findWebAuthnRpId(currentUrl, devices, BETA_DOMAINS)).toBe(
"fgte5-ciaaa-aaaad-aaatq-cai.ic0.app"
);
});

Expand All @@ -74,8 +95,8 @@ describe("findWebAuthnRpId", () => {
];
const currentUrl = "https://identity.internetcomputer.org";

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

Expand All @@ -85,22 +106,27 @@ describe("findWebAuthnRpId", () => {
];
const currentUrl = "https://identity.ic0.app";

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

test("uses preferred domains when provided", () => {
const preferredDomains = ["ic0.app", "icp0.io", "internetcomputer.org"];
// Switch the order of the domains, internetcomputer.org is moved to last.
const switchedDomains = [
"https://identity.ic0.app",
"https://identity.icp0.io",
"https://identity.internetcomputer.org",
];

const devices: CredentialData[] = [
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"
expect(findWebAuthnRpId(currentUrl, devices, switchedDomains)).toBe(
"identity.icp0.io"
);
});

Expand All @@ -110,9 +136,9 @@ describe("findWebAuthnRpId", () => {
];
const currentUrl = "not-a-valid-url";

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

test("throws an error if no devices are registered for the current or preferred domains", () => {
Expand All @@ -121,7 +147,9 @@ describe("findWebAuthnRpId", () => {
];
const currentUrl = "https://identity.ic0.app";

expect(() => findWebAuthnRpId(currentUrl, devices)).toThrowError(
expect(() =>
findWebAuthnRpId(currentUrl, devices, PROD_DOMAINS)
).toThrowError(
"Not possible. Devices must be registered for at least one of the following domains: ic0.app, internetcomputer.org, icp0.io"
);
});
Expand All @@ -130,7 +158,9 @@ describe("findWebAuthnRpId", () => {
const devices: CredentialData[] = [];
const currentUrl = "https://identity.ic0.app";

expect(() => findWebAuthnRpId(currentUrl, devices)).toThrowError(
expect(() =>
findWebAuthnRpId(currentUrl, devices, PROD_DOMAINS)
).toThrowError(
"Not possible. Every registered user has at least one device."
);
});
Expand Down
78 changes: 44 additions & 34 deletions src/frontend/src/utils/findWebAuthnRpId.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,45 @@
import { CredentialData } from "./credential-devices";

// This is used when the origin is empty in the device data.
const DEFAULT_DOMAIN = "https://identity.ic0.app";
export const PROD_DOMAINS = [
"https://identity.ic0.app",
"https://identity.internetcomputer.org",
"https://identity.icp0.io",
];
export const BETA_DOMAINS = [
"https://beta.identity.ic0.app",
"https://beta.identity.internetcomputer.org",
"https://fgte5-ciaaa-aaaad-aaatq-cai.ic0.app",
];

/**
* Helper to extract the top and secondary level domain from a URL.
* Returns the related domains ordered by preference.
*
* 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.
* It reads the current URL and returns the set related to the current url.
*/
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.");
export const relatedDomains = (): string[] => {
const currentUrl = new URL(window.location.origin);
if (PROD_DOMAINS.includes(currentUrl.origin)) {
return PROD_DOMAINS;
}
if (BETA_DOMAINS.includes(currentUrl.origin)) {
return BETA_DOMAINS;
}
// Only beta and prod have related domains.
return [];
};

const sameDomain = (url1: string, url2: string): boolean =>
new URL(url1).hostname === new URL(url2).hostname;

const hostname = (url: string): string => new URL(url).hostname;

return parts.slice(-2).join(".");
const getFirstHostname = (devices: CredentialData[]): string => {
if (devices[0] === undefined) {
throw new Error("Not possible. Call this function only if devices exist.");
}
return hostname(devices[0].origin ?? DEFAULT_DOMAIN);
};

/**
Expand All @@ -35,9 +54,7 @@ const getDevicesForDomain = (
devices: CredentialData[],
domain: string
): CredentialData[] =>
devices.filter(
(d) => getTopAndSecondaryLevelDomain(d.origin ?? DEFAULT_DOMAIN) === domain
);
devices.filter((d) => sameDomain(d.origin ?? DEFAULT_DOMAIN, domain));

/**
* Returns the domain to use as the RP ID for WebAuthn registration.
Expand All @@ -51,8 +68,8 @@ const getDevicesForDomain = (
*
* @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.
* @param relatedDomains - Optional list of domains in order or preference to use as the RP ID.
* @returns {string | undefined} The RP ID (as hostname without schema) 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.
Expand All @@ -62,35 +79,28 @@ const getDevicesForDomain = (
export const findWebAuthnRpId = (
currentUrl: string,
devices: CredentialData[],
preferredDomains: string[] = ["ic0.app", "internetcomputer.org", "icp0.io"]
relatedDomains: string[]
): string | undefined => {
const currentDomain = getTopAndSecondaryLevelDomain(currentUrl);

// If there are no related domains, RP ID should not be set.
if (relatedDomains.length === 0) {
return undefined;
}
if (devices.length === 0) {
throw new Error(
"Not possible. Every registered user has at least one device."
);
}

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

// Try current domain first if devices exist
if (getDevicesForDomain(devices, currentDomain).length > 0) {
if (getDevicesForDomain(devices, currentUrl).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) {
for (const domain of relatedDomains) {
const devicesForDomain = getDevicesForDomain(devices, domain);
if (devicesForDomain.length > 0) {
return getFirstDomain(devicesForDomain);
return getFirstHostname(devicesForDomain);
}
}

Expand Down

0 comments on commit fa5bf21

Please sign in to comment.