Skip to content

Commit

Permalink
Helper to find webauthn steps (#2833)
Browse files Browse the repository at this point in the history
* Helper to find webauthn steps

* Add comment not used

* CR changes
  • Loading branch information
lmuntaner authored Feb 4, 2025
1 parent 65a6cbe commit 67d6861
Show file tree
Hide file tree
Showing 2 changed files with 158 additions and 0 deletions.
91 changes: 91 additions & 0 deletions src/frontend/src/utils/findWebAuthnSteps.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { LEGACY_II_URL } from "$src/config";
import { CredentialData } from "./credential-devices";
import { PROD_DOMAINS } from "./findWebAuthnRpId";
import { findWebAuthnSteps } from "./findWebAuthnSteps";

describe("findWebAuthnSteps", () => {
const currentOrigin = "https://identity.internetcomputer.org";
const nonCurrentOrigin1 = "https://identity.ic0.app";
const nonCurrentOrigin1RpId = new URL(nonCurrentOrigin1).hostname;
const nonCurrentOrigin2 = "https://identity.icp0.io";
const nonCurrentOrigin2RpId = new URL(nonCurrentOrigin2).hostname;
const relatedOrigins = PROD_DOMAINS;

const createMockCredential = (
origin: string | undefined
): CredentialData => ({
pubkey: new ArrayBuffer(32),
credentialId: new ArrayBuffer(16),
origin,
});

it("should return an empty array if no devices are provided", () => {
const result = findWebAuthnSteps({
supportsRor: true,
devices: [],
currentOrigin: currentOrigin,
relatedOrigins,
});
expect(result).toEqual([]);
});

it("should use iframe if the RP ID does not match the current origin", () => {
const result = findWebAuthnSteps({
supportsRor: true,
devices: [createMockCredential(nonCurrentOrigin1)],
currentOrigin: currentOrigin,
relatedOrigins,
});

expect(result).toEqual([{ useIframe: true, rpId: nonCurrentOrigin1RpId }]);
});

it("should not use iframe if the RP ID matches the current origin", () => {
const result = findWebAuthnSteps({
supportsRor: true,
devices: [
createMockCredential(currentOrigin),
createMockCredential(currentOrigin),
],
currentOrigin: currentOrigin,
relatedOrigins,
});

expect(result).toEqual([{ useIframe: false, rpId: undefined }]);
});

it("should use ic0.app when origin is undefined", () => {
const result = findWebAuthnSteps({
supportsRor: true,
devices: [
createMockCredential(undefined),
createMockCredential(LEGACY_II_URL),
],
currentOrigin: LEGACY_II_URL,
relatedOrigins,
});

expect(result).toEqual([{ useIframe: false, rpId: undefined }]);
});

it("should handle multiple RP IDs and filter credentials accordingly", () => {
const result = findWebAuthnSteps({
supportsRor: true,
devices: [
createMockCredential(currentOrigin),
createMockCredential(currentOrigin),
createMockCredential(nonCurrentOrigin1),
createMockCredential(nonCurrentOrigin2),
createMockCredential(nonCurrentOrigin2),
],
currentOrigin: currentOrigin,
relatedOrigins,
});

expect(result).toEqual([
{ useIframe: false, rpId: undefined },
{ useIframe: true, rpId: nonCurrentOrigin1RpId },
{ useIframe: true, rpId: nonCurrentOrigin2RpId },
]);
});
});
67 changes: 67 additions & 0 deletions src/frontend/src/utils/findWebAuthnSteps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { CredentialData } from "./credential-devices";
import {
excludeCredentialsFromOrigins,
findWebAuthnRpId,
} from "./findWebAuthnRpId";

export type WebAuthnStep = {
useIframe: boolean;
rpId: string | undefined;
};
type Parameters = {
// Does the user support Related Origin Requests?
// Two sources are checked: the user agent and whether the user uses a third party provider for passkweys.
// Not used at the moment.
supportsRor: boolean;
devices: CredentialData[];
currentOrigin: string;
relatedOrigins: string[];
};

/**
* Function that returns the ordered steps to try to perform the webauthn authentication.
*
* There are two dimensions in the steps:
* - Use iframe for the webauthn authentication or not.
* - Which RP ID to use. This is used for the iframe or for Related Origin Requests.
*
* Logic:
* - To calculate the RP ID, we use the `findWebAuthnRpId` function.
* - Calculate the RP ID first with all the credentials.
* - For the subsequent RP IDs, the credentials' origin that matches the previous RP ID will be excluded.
* - At the moment, we only use non-iframe if the RP ID matches the current origin. to avoid bad UX, if the RP ID doesn't match the current origin, the iframe will be used.
*
* @param {Parameters} params - The parameters to find the webauthn steps.
* @returns {WebAuthnStep[]} The ordered steps to try to perform the webauthn authentication.
*/
export const findWebAuthnSteps = ({
devices,
currentOrigin,
relatedOrigins,
}: Parameters): WebAuthnStep[] => {
const steps: WebAuthnStep[] = [];
let filteredCredentials = devices;
const rpIds = new Set<string | undefined>();

while (filteredCredentials.length > 0) {
const rpId = findWebAuthnRpId(
currentOrigin,
filteredCredentials,
relatedOrigins
);
// EXCEPTION: At the moment, to avoid bad UX, if the RP ID doesn't match the current origin, the iframe will be used.
// This is because it's hard to find out whether a user's credentials come from a third party password manager or not.
// The iframe workaround works for all users.
const useIframe =
rpId !== undefined && rpId !== new URL(currentOrigin).hostname;
steps.push({ useIframe, rpId });
rpIds.add(rpId);
filteredCredentials = excludeCredentialsFromOrigins(
filteredCredentials,
rpIds,
currentOrigin
);
}

return steps;
};

0 comments on commit 67d6861

Please sign in to comment.