diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 7722138b88f..40e22cfbb5a 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -76,6 +76,7 @@ export enum FeatureFlag { /* Platform */ ContentScriptIpcChannelFramework = "content-script-ipc-channel-framework", + WebAuthnRelatedOrigins = "pm-30529-webauthn-related-origins", /* Innovation */ PM19148_InnovationArchive = "pm-19148-innovation-archive", @@ -171,6 +172,7 @@ export const DefaultFeatureFlagValue = { /* Platform */ [FeatureFlag.ContentScriptIpcChannelFramework]: FALSE, + [FeatureFlag.WebAuthnRelatedOrigins]: FALSE, /* Innovation */ [FeatureFlag.PM19148_InnovationArchive]: FALSE, diff --git a/libs/common/src/platform/services/fido2/domain-utils.spec.ts b/libs/common/src/platform/services/fido2/domain-utils.spec.ts index 284555052dd..df6132bac20 100644 --- a/libs/common/src/platform/services/fido2/domain-utils.spec.ts +++ b/libs/common/src/platform/services/fido2/domain-utils.spec.ts @@ -2,101 +2,377 @@ import { isValidRpId } from "./domain-utils"; // Spec: If options.rp.id is not a registrable domain suffix of and is not equal to effectiveDomain, return a DOMException whose name is "SecurityError", and terminate this algorithm. describe("validateRpId", () => { - it("should not be valid when rpId is null", () => { - const origin = "example.com"; + let mockFetch: jest.Mock; + let webAuthnRelatedOriginsFeatureFlag = false; - expect(isValidRpId(null, origin)).toBe(false); + beforeEach(() => { + mockFetch = jest.fn(); + // Default: ROR requests fail (no .well-known/webauthn endpoint) + mockFetch.mockRejectedValue(new Error("Network error")); }); - it("should not be valid when origin is null", () => { - const rpId = "example.com"; + describe("classic domain validation", () => { + it("should not be valid when rpId is null", async () => { + const origin = "example.com"; - expect(isValidRpId(rpId, null)).toBe(false); + expect(await isValidRpId(null, origin, webAuthnRelatedOriginsFeatureFlag)).toBe(false); + }); + + it("should not be valid when origin is null", async () => { + const rpId = "example.com"; + + expect(await isValidRpId(rpId, null, webAuthnRelatedOriginsFeatureFlag)).toBe(false); + }); + + it("should not be valid when rpId is more specific than origin", async () => { + const rpId = "sub.login.bitwarden.com"; + const origin = "https://login.bitwarden.com:1337"; + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + false, + ); + }); + + it("should not be valid when effective domains of rpId and origin do not match", async () => { + const rpId = "passwordless.dev"; + const origin = "https://login.bitwarden.com:1337"; + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + false, + ); + }); + + it("should not be valid when subdomains are the same but effective domains of rpId and origin do not match", async () => { + const rpId = "login.passwordless.dev"; + const origin = "https://login.bitwarden.com:1337"; + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + false, + ); + }); + + it("should not be valid when rpId and origin are both different TLD", async () => { + const rpId = "bitwarden"; + const origin = "localhost"; + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + false, + ); + }); + + // Only allow localhost for rpId, need to properly investigate the implications of + // adding support for ip-addresses and other TLDs + it("should not be valid when rpId and origin are both the same TLD", async () => { + const rpId = "bitwarden"; + const origin = "bitwarden"; + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + false, + ); + }); + + it("should not be valid when rpId and origin are ip-addresses", async () => { + const rpId = "127.0.0.1"; + const origin = "127.0.0.1"; + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + false, + ); + }); + + it("should be valid when domains of rpId and origin are localhost", async () => { + const rpId = "localhost"; + const origin = "https://localhost:8080"; + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + true, + ); + }); + + it("should be valid when domains of rpId and origin are the same", async () => { + const rpId = "bitwarden.com"; + const origin = "https://bitwarden.com"; + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + true, + ); + }); + + it("should be valid when origin is a subdomain of rpId", async () => { + const rpId = "bitwarden.com"; + const origin = "https://login.bitwarden.com:1337"; + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + true, + ); + }); + + it("should be valid when domains of rpId and origin are the same and they are both subdomains", async () => { + const rpId = "login.bitwarden.com"; + const origin = "https://login.bitwarden.com:1337"; + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + true, + ); + }); + + it("should be valid when origin is a subdomain of rpId and they are both subdomains", async () => { + const rpId = "login.bitwarden.com"; + const origin = "https://sub.login.bitwarden.com:1337"; + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + true, + ); + }); + + it("should not be valid for a partial match of a subdomain", async () => { + const rpId = "accounts.example.com"; + const origin = "https://evilaccounts.example.com"; + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag)).toBe(false); + }); }); - it("should not be valid when rpId is more specific than origin", () => { - const rpId = "sub.login.bitwarden.com"; - const origin = "https://login.bitwarden.com:1337"; + describe("Related Origin Requests (ROR)", () => { + // Helper to create a mock fetch response + function mockRorResponse(origins: string[], status = 200, contentType = "application/json") { + mockFetch.mockResolvedValue({ + ok: status >= 200 && status < 300, + status, + headers: new Headers({ "content-type": contentType }), + json: async () => ({ origins }), + }); + } - expect(isValidRpId(rpId, origin)).toBe(false); - }); + it("should not proceed with ROR check when valid when feature flag disabled", async () => { + const rpId = "accounts.meta.com"; + const origin = "https://accountscenter.facebook.com"; - it("should not be valid when effective domains of rpId and origin do not match", () => { - const rpId = "passwordless.dev"; - const origin = "https://login.bitwarden.com:1337"; + mockRorResponse([origin, "https://www.facebook.com", "https://www.instagram.com"]); - expect(isValidRpId(rpId, origin)).toBe(false); - }); + expect(await isValidRpId(rpId, origin, false, mockFetch)).toBe(false); + expect(mockFetch).not.toHaveBeenCalledWith( + `https://${rpId}/.well-known/webauthn`, + expect.objectContaining({ + credentials: "omit", + referrerPolicy: "no-referrer", + }), + ); + }); - it("should not be valid when subdomains are the same but effective domains of rpId and origin do not match", () => { - const rpId = "login.passwordless.dev"; - const origin = "https://login.bitwarden.com:1337"; + webAuthnRelatedOriginsFeatureFlag = true; - expect(isValidRpId(rpId, origin)).toBe(false); - }); + it("should be valid when origin is listed in .well-known/webauthn", async () => { + const rpId = "accounts.meta.com"; + const origin = "https://accountscenter.facebook.com"; - it("should not be valid when rpId and origin are both different TLD", () => { - const rpId = "bitwarden"; - const origin = "https://localhost"; + mockRorResponse([origin, "https://www.facebook.com", "https://www.instagram.com"]); - expect(isValidRpId(rpId, origin)).toBe(false); - }); + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + true, + ); + expect(mockFetch).toHaveBeenCalledWith( + `https://${rpId}/.well-known/webauthn`, + expect.objectContaining({ + credentials: "omit", + referrerPolicy: "no-referrer", + }), + ); + }); - // Only allow localhost for rpId, need to properly investigate the implications of - // adding support for ip-addresses and other TLDs - it("should not be valid when rpId and origin are both the same TLD", () => { - const rpId = "bitwarden"; - const origin = "https://bitwarden"; + it("should not be valid when origin is not listed in .well-known/webauthn", async () => { + const rpId = "accounts.meta.com"; + const origin = "https://evil.com"; - expect(isValidRpId(rpId, origin)).toBe(false); - }); + mockRorResponse(["https://www.facebook.com", "https://www.instagram.com"]); - it("should not be valid when rpId and origin are ip-addresses", () => { - const rpId = "127.0.0.1"; - const origin = "https://127.0.0.1"; + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + false, + ); + }); - expect(isValidRpId(rpId, origin)).toBe(false); - }); + it("should not be valid when .well-known/webauthn returns non-200 status", async () => { + const rpId = "accounts.meta.com"; + const origin = "https://accountscenter.facebook.com"; - it("should be valid when domains of rpId and origin are localhost", () => { - const rpId = "localhost"; - const origin = "https://localhost:8080"; + mockFetch.mockResolvedValue({ + ok: false, + status: 404, + headers: new Headers({ "content-type": "application/json" }), + }); - expect(isValidRpId(rpId, origin)).toBe(true); - }); + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + false, + ); + }); - it("should be valid when domains of rpId and origin are the same", () => { - const rpId = "bitwarden.com"; - const origin = "https://bitwarden.com"; + it("should not be valid when .well-known/webauthn returns non-JSON content-type", async () => { + const rpId = "accounts.meta.com"; + const origin = "https://accountscenter.facebook.com"; - expect(isValidRpId(rpId, origin)).toBe(true); - }); + mockRorResponse([origin], 200, "text/html"); - it("should be valid when origin is a subdomain of rpId", () => { - const rpId = "bitwarden.com"; - const origin = "https://login.bitwarden.com:1337"; + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + false, + ); + }); - expect(isValidRpId(rpId, origin)).toBe(true); - }); + it("should not be valid when .well-known/webauthn response has no origins array", async () => { + const rpId = "accounts.meta.com"; + const origin = "https://accountscenter.facebook.com"; - it("should be valid when domains of rpId and origin are the same and they are both subdomains", () => { - const rpId = "login.bitwarden.com"; - const origin = "https://login.bitwarden.com:1337"; + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ notOrigins: "invalid" }), + }); - expect(isValidRpId(rpId, origin)).toBe(true); - }); + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + false, + ); + }); - it("should be valid when origin is a subdomain of rpId and they are both subdomains", () => { - const rpId = "login.bitwarden.com"; - const origin = "https://sub.login.bitwarden.com:1337"; + it("should not be valid when .well-known/webauthn response has empty origins array", async () => { + const rpId = "accounts.meta.com"; + const origin = "https://accountscenter.facebook.com"; - expect(isValidRpId(rpId, origin)).toBe(true); - }); + mockRorResponse([]); - it("should not be valid for a partial match of a subdomain", () => { - const rpId = "accounts.example.com"; - const origin = "https://evilaccounts.example.com"; + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + false, + ); + }); - expect(isValidRpId(rpId, origin)).toBe(false); + it("should not be valid when .well-known/webauthn response has non-string origins", async () => { + const rpId = "accounts.meta.com"; + const origin = "https://accountscenter.facebook.com"; + + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ origins: [123, { url: origin }] }), + }); + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + false, + ); + }); + + it("should not be valid when fetch throws an error", async () => { + const rpId = "accounts.meta.com"; + const origin = "https://accountscenter.facebook.com"; + + mockFetch.mockRejectedValue(new Error("Network error")); + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + false, + ); + }); + + it("should not be valid when fetch times out", async () => { + const rpId = "accounts.meta.com"; + const origin = "https://accountscenter.facebook.com"; + + mockFetch.mockRejectedValue(new DOMException("The operation was aborted.", "AbortError")); + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + false, + ); + }); + + it("should skip classic validation and use ROR when domains do not match", async () => { + // This is the Facebook/Meta use case + const rpId = "accounts.meta.com"; + const origin = "https://accountscenter.facebook.com"; + + mockRorResponse([origin]); + + // Classic validation would fail (different domains), but ROR should succeed + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + true, + ); + }); + + it("should not call ROR endpoint when classic validation succeeds", async () => { + const rpId = "bitwarden.com"; + const origin = "https://bitwarden.com"; + + // Classic validation succeeds, so ROR should not be called + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + true, + ); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("should require exact origin match (including port)", async () => { + const rpId = "accounts.meta.com"; + const origin = "https://accountscenter.facebook.com:8443"; + + // Only the non-port version is listed + mockRorResponse(["https://accountscenter.facebook.com"]); + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + false, + ); + }); + + it("should handle invalid URLs in origins array gracefully", async () => { + const rpId = "accounts.meta.com"; + const origin = "https://accountscenter.facebook.com"; + + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ + origins: ["not-a-valid-url", "://also-invalid", origin], + }), + }); + + // Should still find the valid origin despite invalid entries + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + true, + ); + }); + + it("should enforce max labels limit", async () => { + const rpId = "example.com"; + const origin = "https://site6.com"; + + // Create origins from 6 different eTLD+1 labels + // Only the first 5 should be processed + mockRorResponse([ + "https://site1.com", + "https://site2.com", + "https://site3.com", + "https://site4.com", + "https://site5.com", + "https://site6.com", // This is the 6th label, should be skipped + ]); + + // The origin is in the list but should be skipped due to max labels limit + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + false, + ); + }); + + it("should allow multiple origins from the same eTLD+1", async () => { + const rpId = "example.com"; + const origin = "https://sub2.facebook.com"; + + // All these are from facebook.com (same eTLD+1), so they count as 1 label + mockRorResponse([ + "https://www.facebook.com", + "https://sub1.facebook.com", + "https://sub2.facebook.com", + "https://sub3.facebook.com", + ]); + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + true, + ); + }); }); }); diff --git a/libs/common/src/platform/services/fido2/domain-utils.ts b/libs/common/src/platform/services/fido2/domain-utils.ts index 542beae3435..dafc270ea9a 100644 --- a/libs/common/src/platform/services/fido2/domain-utils.ts +++ b/libs/common/src/platform/services/fido2/domain-utils.ts @@ -1,22 +1,39 @@ import { parse } from "tldts"; /** - * Validates whether a Relying Party ID (rpId) is valid for a given origin according to WebAuthn specifications. + * Maximum number of unique eTLD+1 labels to process when checking Related Origin Requests. + * This limit prevents malicious servers from causing excessive processing. + * Per WebAuthn spec recommendation. + */ +const ROR_MAX_LABELS = 5; + +/** + * Timeout in milliseconds for fetching the .well-known/webauthn endpoint. + */ +const ROR_FETCH_TIMEOUT_MS = 5000; + +/** + * Validates whether a Relying Party ID (rpId) is valid for a given origin according to classic + * WebAuthn specifications (before Related Origin Requests extension). * - * The validation enforces the following rules: - * - The origin must use the HTTPS scheme + * This implements the core WebAuthn RP ID validation logic: + * - The origin must use the HTTPS scheme (except localhost) * - Both rpId and origin must be valid domain names (not IP addresses) - * - Both must have the same registrable domain (e.g., example.com) + * - Both must have the same registrable domain (eTLD+1) * - The origin must either exactly match the rpId or be a subdomain of it * - Single-label domains are rejected unless they are 'localhost' * - Localhost is always valid when both rpId and origin are localhost * + * This is used internally as the first validation step before falling back to + * Related Origin Requests (ROR) validation. + * + * @see https://www.w3.org/TR/webauthn-2/#rp-id + * * @param rpId - The Relying Party identifier to validate * @param origin - The origin URL to validate against (must start with https://) * @returns `true` if the rpId is valid for the given origin, `false` otherwise - * */ -export function isValidRpId(rpId: string, origin: string) { +function isValidRpIdInternal(rpId: string, origin: string) { if (!rpId || !origin) { return false; } @@ -73,6 +90,148 @@ export function isValidRpId(rpId: string, origin: string) { if (parsedOrigin.hostname != null && parsedOrigin.hostname.endsWith("." + rpId)) { return true; } - - return false; +} + +/** + * Checks if the origin is allowed to use the given rpId via Related Origin Requests (ROR). + * This implements the WebAuthn Related Origin Requests spec which allows an RP to + * authorize origins from different domains to use its rpId. + * + * @see https://w3c.github.io/webauthn/#sctn-related-origins + * + * @param rpId - The relying party ID being requested + * @param origin - The origin making the WebAuthn request + * @param fetchFn - Optional fetch function for testing, defaults to global fetch + * @returns Promise that resolves to true if the origin is allowed via ROR, false otherwise + */ +async function isAllowedByRor( + rpId: string, + origin: string, + fetchFn?: typeof fetch, +): Promise { + try { + const fetchImpl = fetchFn ?? globalThis.fetch; + + // Create abort signal with timeout - use AbortSignal.timeout if available, otherwise use AbortController + let signal: AbortSignal; + if (typeof AbortSignal.timeout === "function") { + signal = AbortSignal.timeout(ROR_FETCH_TIMEOUT_MS); + } else { + const controller = new AbortController(); + setTimeout(() => controller.abort(), ROR_FETCH_TIMEOUT_MS); + signal = controller.signal; + } + + const response = await fetchImpl(`https://${rpId}/.well-known/webauthn`, { + credentials: "omit", + referrerPolicy: "no-referrer", + signal, + }); + + if (!response.ok) { + return false; + } + + const contentType = response.headers.get("content-type"); + if (!contentType || !contentType.includes("application/json")) { + return false; + } + + const data = (await response.json()) as { origins?: unknown }; + + if ( + !data || + !Array.isArray(data.origins) || + !data.origins.every((o) => typeof o === "string") || + data.origins.length === 0 + ) { + return false; + } + + // Track unique labels (eTLD+1) to enforce the max labels limit + const labelsSeen = new Set(); + + for (const allowedOrigin of data.origins as string[]) { + try { + const url = new URL(allowedOrigin); + const hostname = url.hostname; + if (!hostname) { + continue; + } + + const parsed = parse(hostname, { allowPrivateDomains: true }); + if (!parsed.domain || !parsed.publicSuffix) { + continue; + } + + // Extract the label (the part before the public suffix) + const label = parsed.domain.slice(0, parsed.domain.length - parsed.publicSuffix.length - 1); + + if (!label) { + continue; + } + + // Skip if we've already seen max labels and this is a new one + if (labelsSeen.size >= ROR_MAX_LABELS && !labelsSeen.has(label)) { + continue; + } + + // Check for exact origin match + if (origin === allowedOrigin) { + return true; + } + + // Track the label if we haven't hit the limit + if (labelsSeen.size < ROR_MAX_LABELS) { + labelsSeen.add(label); + } + } catch { + // Invalid URL, skip this entry + continue; + } + } + + return false; + } catch { + // Network error, timeout, or other failure - fail closed + return false; + } +} + +/* Validates whether a Relying Party ID (rpId) is valid for a given origin according to WebAuthn specifications. + * If that fails, checks if the origin is authorized via Related Origin Requests (ROR). + * + * The validation enforces the following rules: + * - The origin must use the HTTPS scheme + * - Both rpId and origin must be valid domain names (not IP addresses) + * - Both must have the same registrable domain (e.g., example.com) + * - The origin must either exactly match the rpId or be a subdomain of it + * - Single-label domains are rejected unless they are 'localhost' + * - Localhost is always valid when both rpId and origin are localhost + * + * @param rpId - The Relying Party identifier to validate + * @param origin - The origin URL to validate against (must start with https://) + * @param fetchFn - Optional fetch function for testing, defaults to global fetch + * @returns `true` if the rpId is valid for the given origin, `false` otherwise + * + */ +export async function isValidRpId( + rpId: string, + origin: string, + relatedOriginChecksEnabled: boolean, + fetchFn?: typeof fetch, +): Promise { + // Classic WebAuthn validation: rpId must be a registrable domain suffix of the origin + const classicMatch = isValidRpIdInternal(rpId, origin); + + if (classicMatch) { + return true; + } + + if (!relatedOriginChecksEnabled) { + return false; + } + + // Fall back to Related Origin Requests (ROR) validation + return await isAllowedByRor(rpId, origin, fetchFn); } diff --git a/libs/common/src/platform/services/fido2/fido2-client.service.spec.ts b/libs/common/src/platform/services/fido2/fido2-client.service.spec.ts index 4fd91fb19e6..7b298110040 100644 --- a/libs/common/src/platform/services/fido2/fido2-client.service.spec.ts +++ b/libs/common/src/platform/services/fido2/fido2-client.service.spec.ts @@ -71,6 +71,8 @@ describe("FidoAuthenticatorService", () => { isValidRpId = jest.spyOn(DomainUtils, "isValidRpId"); + configService.getFeatureFlag$.mockReturnValue(of(false)); + client = new Fido2ClientService( authenticator, configService, @@ -186,7 +188,7 @@ describe("FidoAuthenticatorService", () => { const params = createParams(); authenticator.makeCredential.mockResolvedValue(createAuthenticatorMakeResult()); // `params` actually has a valid rp.id, but we're mocking the function to return false - isValidRpId.mockReturnValue(false); + isValidRpId.mockResolvedValue(false); const result = async () => await client.createCredential(params, windowReference); @@ -459,7 +461,7 @@ describe("FidoAuthenticatorService", () => { const params = createParams(); authenticator.getAssertion.mockResolvedValue(createAuthenticatorAssertResult()); // `params` actually has a valid rp.id, but we're mocking the function to return false - isValidRpId.mockReturnValue(false); + isValidRpId.mockResolvedValue(false); const result = async () => await client.assertCredential(params, windowReference); diff --git a/libs/common/src/platform/services/fido2/fido2-client.service.ts b/libs/common/src/platform/services/fido2/fido2-client.service.ts index 2aa618e974d..8fabed450f8 100644 --- a/libs/common/src/platform/services/fido2/fido2-client.service.ts +++ b/libs/common/src/platform/services/fido2/fido2-client.service.ts @@ -3,6 +3,8 @@ import { firstValueFrom, Subscription } from "rxjs"; import { parse } from "tldts"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; + import { AuthService } from "../../../auth/abstractions/auth.service"; import { AuthenticationStatus } from "../../../auth/enums/authentication-status"; import { DomainSettingsService } from "../../../autofill/services/domain-settings.service"; @@ -62,6 +64,9 @@ export class Fido2ClientService< MAX: 600000, }, }; + protected readonly relatedOriginChecksEnabled$ = this.configService.getFeatureFlag$( + FeatureFlag.WebAuthnRelatedOrigins, + ); constructor( private authenticator: Fido2AuthenticatorService, @@ -142,7 +147,13 @@ export class Fido2ClientService< throw new DOMException("'origin' is not a valid https origin", "SecurityError"); } - if (!isValidRpId(params.rp.id, params.origin)) { + if ( + !(await isValidRpId( + params.rp.id, + params.origin, + await firstValueFrom(this.relatedOriginChecksEnabled$), + )) + ) { this.logService?.warning( `[Fido2Client] 'rp.id' cannot be used with the current origin: rp.id = ${params.rp.id}; origin = ${params.origin}`, ); @@ -281,7 +292,13 @@ export class Fido2ClientService< throw new DOMException("'origin' is not a valid https origin", "SecurityError"); } - if (!isValidRpId(params.rpId, params.origin)) { + if ( + !(await isValidRpId( + params.rpId, + params.origin, + await firstValueFrom(this.relatedOriginChecksEnabled$), + )) + ) { this.logService?.warning( `[Fido2Client] 'rp.id' cannot be used with the current origin: rp.id = ${params.rpId}; origin = ${params.origin}`, );