1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-11 22:13:32 +00:00

[PM-30529][PM-31279] Webauthn: Support Related Origin Requests (#18242)

* Webauthn: Support Related Origin Requests

* review changes

* PM-31279 Add feature flag to guard executing ROR checks

* Fix fido2-client.service tests

* Set ROR_MAX_LABELS to 5

---------

Co-authored-by: Daniel James Smith <djsmith85@users.noreply.github.com>
Co-authored-by: Addison Beck <github@addisonbeck.com>
This commit is contained in:
Jon David Schober
2026-02-11 09:32:22 -06:00
committed by GitHub
parent d18ddd3480
commit 29e2be0d2b
5 changed files with 535 additions and 79 deletions

View File

@@ -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,

View File

@@ -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,
);
});
});
});

View File

@@ -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<boolean> {
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<string>();
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<boolean> {
// 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);
}

View File

@@ -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);

View File

@@ -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<ParentWindowReference>,
@@ -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}`,
);