Skip to content

Commit

Permalink
Util getCredentialsOrigin (#2793)
Browse files Browse the repository at this point in the history
* Util getCredentialsOrigin

* CR changes

* Rename constant
  • Loading branch information
lmuntaner authored Jan 21, 2025
1 parent 2eab572 commit d5d446e
Show file tree
Hide file tree
Showing 4 changed files with 150 additions and 6 deletions.
2 changes: 2 additions & 0 deletions src/frontend/src/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// If credentials have no origin, it's because they were created in this domain
export const II_LEGACY_ORIGIN = "https://identity.ic0.app";
101 changes: 101 additions & 0 deletions src/frontend/src/utils/credential-devices.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { DeviceData } from "$generated/internet_identity_types";
import { getCredentialsOrigin } from "./credential-devices";

describe("credetial-devices test", () => {
describe("getCredentialsOrigin", () => {
const createDevice = (
origin: string | undefined
): Omit<DeviceData, "alias"> => ({
origin: origin === undefined ? [] : [origin],
protection: { unprotected: null },
// eslint-disable-next-line
pubkey: undefined as any,
key_type: { unknown: null },
purpose: { authentication: null },
credential_id: [],
metadata: [],
});

const undefinedOriginDevice: Omit<DeviceData, "alias"> =
createDevice(undefined);
const ic0OriginDevice: Omit<DeviceData, "alias"> = createDevice(
"https://identity.ic0.app"
);
const icOrgOriginDevice: Omit<DeviceData, "alias"> = createDevice(
"https://identity.internetcomputer.org"
);
const icIoOriginDevice: Omit<DeviceData, "alias"> = createDevice(
"https://identity.icp0.io"
);
const userAgentSupportingRoR =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
const userAgentNotSupportingRoR =
"Mozilla/5.0 (Android 13; Mobile; rv:132.0) Gecko/132.0 Firefox/132.0";

it("should return a set of origins", () => {
expect(
getCredentialsOrigin({
credentials: [ic0OriginDevice, icOrgOriginDevice, icIoOriginDevice],
userAgent: userAgentSupportingRoR,
})
).toBeUndefined();

expect(
getCredentialsOrigin({
credentials: [
ic0OriginDevice,
{ ...ic0OriginDevice },
icIoOriginDevice,
],
userAgent: userAgentSupportingRoR,
})
).toBeUndefined();

expect(
getCredentialsOrigin({
credentials: [ic0OriginDevice, { ...ic0OriginDevice }],
userAgent: userAgentSupportingRoR,
})
).toBe("https://identity.ic0.app");

expect(
getCredentialsOrigin({
credentials: [icOrgOriginDevice, { ...icOrgOriginDevice }],
userAgent: userAgentSupportingRoR,
})
).toBe("https://identity.internetcomputer.org");
});

it("should consider `undefined` as the default domain", () => {
expect(
getCredentialsOrigin({
credentials: [undefinedOriginDevice],
userAgent: userAgentSupportingRoR,
})
).toBe("https://identity.ic0.app");

expect(
getCredentialsOrigin({
credentials: [undefinedOriginDevice, ic0OriginDevice],
userAgent: userAgentSupportingRoR,
})
).toBe("https://identity.ic0.app");

expect(
getCredentialsOrigin({
credentials: [undefinedOriginDevice, icOrgOriginDevice],
userAgent: userAgentSupportingRoR,
})
).toBeUndefined();
});

it("returns `undefined` if user doesn't support RoR", () => {
expect(
getCredentialsOrigin({
credentials: [ic0OriginDevice, icOrgOriginDevice, icIoOriginDevice],
userAgent: userAgentNotSupportingRoR,
})
).toBeUndefined();
});
});
});
42 changes: 42 additions & 0 deletions src/frontend/src/utils/credential-devices.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { DeviceData, DeviceKey } from "$generated/internet_identity_types";
import { II_LEGACY_ORIGIN } from "$src/constants";
import { DerEncodedPublicKey } from "@dfinity/agent";
import { supportsWebauthRoR } from "./userAgent";

export type CredentialId = ArrayBuffer;
export type CredentialData = {
Expand Down Expand Up @@ -32,3 +34,43 @@ export const convertToValidCredentialData = (
origin: device.origin[0],
};
};

/**
* Helper to encapsulate the logic of finding the RP ID needed when a device will be added.
*
* We want to avoid a bad UX when users log in.
* If the user has multiple devices registered in different origins,
* it can lead to bad UX when calculating the RP ID.
*
* Therefore, we want to avoid devices registered in multiple origins.
*
* First, it checks whether the browser supports ROR.
* Second, it checks whether all the devices have the same origin.
* - If they do, it returns the origin.
* - If they don't, it returns `undefined`.
*
* @param {Object} params
* @param {DeviceData[]} params.credentials - The devices to check.
* @param {string} params.userAgent - The user agent string.
* @returns {string | undefined} The origin to use when adding a new device.
* - If `undefined` then no common origin was found. Probalby use `window.origin` or `undefined` for RP ID.
* - If `string` then the origin can be used to add a new device. Remember to use the hostname only for RP ID.
*/
export const getCredentialsOrigin = ({
credentials,
userAgent,
}: {
credentials: Omit<DeviceData, "alias">[];
userAgent: string;
}): string | undefined => {
if (!supportsWebauthRoR(userAgent)) {
return undefined;
}
const credentialOrigins = new Set(
credentials.map((c) => c.origin[0] ?? II_LEGACY_ORIGIN)
);
if (credentialOrigins.size === 1) {
return credentialOrigins.values().next().value;
}
return undefined;
};
11 changes: 5 additions & 6 deletions src/frontend/src/utils/findWebAuthnRpId.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { II_LEGACY_ORIGIN } from "$src/constants";
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",
Expand Down Expand Up @@ -33,7 +32,7 @@ export const relatedDomains = (): string[] => {
export const hasCredentialsFromMultipleOrigins = (
credentials: CredentialData[]
): boolean =>
new Set(credentials.map(({ origin }) => origin ?? DEFAULT_DOMAIN)).size > 1;
new Set(credentials.map(({ origin }) => origin ?? II_LEGACY_ORIGIN)).size > 1;

/**
* Filters out credentials from specific origins.
Expand Down Expand Up @@ -62,7 +61,7 @@ export const excludeCredentialsFromOrigins = (
return credentials.filter(
(credential) =>
originsToExclude.filter((originToExclude) =>
sameDomain(credential.origin ?? DEFAULT_DOMAIN, originToExclude)
sameDomain(credential.origin ?? II_LEGACY_ORIGIN, originToExclude)
).length === 0
);
};
Expand All @@ -76,7 +75,7 @@ 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);
return hostname(devices[0].origin ?? II_LEGACY_ORIGIN);
};

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

/**
* Returns the domain to use as the RP ID for WebAuthn registration.
Expand Down

0 comments on commit d5d446e

Please sign in to comment.