diff --git a/src/pages/Profile.tsx b/src/pages/Profile.tsx index 596d2cc..d78a5dd 100644 --- a/src/pages/Profile.tsx +++ b/src/pages/Profile.tsx @@ -2,7 +2,11 @@ import { type ChangeEvent, type FormEvent, useEffect, useState } from "react"; import toast from "react-hot-toast"; import InputField from "../components/InputField"; import { BASE_URL } from "../config"; -import { base64urlToUint8Array, bufferToBase64url } from "../utils/webauthn"; +import { + base64urlToUint8Array, + bufferToBase64url, + validateWebAuthnOptionsRpId, +} from "../utils/webauthn"; interface PasskeyAuthenticator { credentialID?: string; @@ -109,6 +113,13 @@ const Profile = () => { user: { id: string; [key: string]: unknown }; }; + const rpIdValidation = validateWebAuthnOptionsRpId(options); + if (!rpIdValidation.isValid) { + console.error("Invalid WebAuthn register RP ID", rpIdValidation); + setMessage(rpIdValidation.message || "Invalid passkey configuration"); + return; + } + const publicKey: PublicKeyCredentialCreationOptions = { ...options, challenge: base64urlToUint8Array(options.challenge).slice(0) diff --git a/src/pages/Signin.test.tsx b/src/pages/Signin.test.tsx index 5405d25..ce8bb48 100644 --- a/src/pages/Signin.test.tsx +++ b/src/pages/Signin.test.tsx @@ -144,9 +144,34 @@ describe("Signin – passkey tab", () => { ); }); + it("fails fast when the backend returns a URL-shaped RP ID", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: true, + json: async () => ({ + challenge: "dGVzdC1jaGFsbGVuZ2U", + rpId: "https://rc-store.benhalverson.dev", + }), + } as Response); + + const toast = await import("react-hot-toast"); + const user = await switchToPasskeyTab(); + + await user.click(screen.getByRole("button", { name: /passkey login/i })); + + await waitFor(() => + expect(toast.default.error).toHaveBeenCalledWith( + expect.stringContaining("Passkeys are unavailable on this deployment"), + expect.anything(), + ), + ); + + expect(navigator.credentials.get).not.toHaveBeenCalled(); + }); + it("sends correct payload to verify-authentication and navigates on success", async () => { const fakeOptions = { challenge: "dGVzdC1jaGFsbGVuZ2U", + rpId: "localhost", allowCredentials: [{ id: "Y3JlZC1pZA", type: "public-key" }], timeout: 60000, }; diff --git a/src/pages/Signin.tsx b/src/pages/Signin.tsx index 8af736a..9900a90 100644 --- a/src/pages/Signin.tsx +++ b/src/pages/Signin.tsx @@ -6,7 +6,11 @@ import { useNavigate } from "react-router-dom"; import { z } from "zod"; import { BASE_URL } from "../config"; import { useAuth } from "../context/AuthContext"; -import { base64urlToUint8Array, bufferToBase64url } from "../utils/webauthn"; +import { + base64urlToUint8Array, + bufferToBase64url, + validateWebAuthnOptionsRpId, +} from "../utils/webauthn"; const Signin = () => { const navigate = useNavigate(); @@ -99,6 +103,12 @@ const Signin = () => { }>; }; + const rpIdValidation = validateWebAuthnOptionsRpId(rawOptions); + if (!rpIdValidation.isValid) { + console.error("Invalid WebAuthn authenticate RP ID", rpIdValidation); + throw new Error(rpIdValidation.message); + } + const publicKey: PublicKeyCredentialRequestOptions = { ...rawOptions, challenge: base64urlToUint8Array( diff --git a/src/pages/Signup.test.tsx b/src/pages/Signup.test.tsx index 469835a..9649da9 100644 --- a/src/pages/Signup.test.tsx +++ b/src/pages/Signup.test.tsx @@ -37,6 +37,14 @@ function renderSignup() { } describe("Signup", () => { + beforeEach(() => { + Object.defineProperty(globalThis.navigator, "credentials", { + value: { create: vi.fn() }, + configurable: true, + writable: true, + }); + }); + afterEach(() => { vi.restoreAllMocks(); mockFetchUser.mockReset(); @@ -126,4 +134,48 @@ describe("Signup", () => { ), ); }); + + it("fails fast when register options include a URL-shaped RP ID", async () => { + vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ success: true }), + } as Response) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + challenge: "dGVzdC1jaGFsbGVuZ2U", + user: { + id: "dXNlci0xMjM", + name: "user@example.com", + displayName: "User Example", + }, + rp: { id: "https://rc-store.benhalverson.dev" }, + }), + } as Response); + mockFetchUser.mockResolvedValueOnce({ email: "user@example.com" }); + + const toast = await import("react-hot-toast"); + + renderSignup(); + const user = userEvent.setup(); + + await user.type(screen.getByLabelText(/email/i), "user@example.com"); + await user.type(screen.getByLabelText(/password/i), "password123"); + await user.click(screen.getByRole("button", { name: /^sign up$/i })); + await screen.findByRole("button", { name: /add passkey to my account/i }); + + await user.click( + screen.getByRole("button", { name: /add passkey to my account/i }), + ); + + await waitFor(() => + expect(toast.default.error).toHaveBeenCalledWith( + expect.stringContaining("Passkeys are unavailable on this deployment"), + expect.anything(), + ), + ); + + expect(navigator.credentials.create).not.toHaveBeenCalled(); + }); }); diff --git a/src/pages/Signup.tsx b/src/pages/Signup.tsx index 9bec488..e12ecfd 100644 --- a/src/pages/Signup.tsx +++ b/src/pages/Signup.tsx @@ -6,7 +6,11 @@ import { useNavigate } from "react-router-dom"; import { z } from "zod"; import { BASE_URL } from "../config"; import { useAuth } from "../context/AuthContext"; -import { base64urlToUint8Array, bufferToBase64url } from "../utils/webauthn"; +import { + base64urlToUint8Array, + bufferToBase64url, + validateWebAuthnOptionsRpId, +} from "../utils/webauthn"; const schema = z.object({ email: z.email({ error: "Invalid email" }), @@ -108,9 +112,16 @@ const Signup = () => { const rawOptions = (await optionsRes.json()) as { challenge: string; user: { id: string; name: string; displayName: string }; + rp?: { id?: string; name?: string }; [key: string]: unknown; }; + const rpIdValidation = validateWebAuthnOptionsRpId(rawOptions); + if (!rpIdValidation.isValid) { + console.error("Invalid WebAuthn register RP ID", rpIdValidation); + throw new Error(rpIdValidation.message); + } + const options = { ...rawOptions, challenge: base64urlToUint8Array(rawOptions.challenge).slice(0) diff --git a/src/utils/webauthn.test.ts b/src/utils/webauthn.test.ts new file mode 100644 index 0000000..f815ae3 --- /dev/null +++ b/src/utils/webauthn.test.ts @@ -0,0 +1,67 @@ +import { + formatWebAuthnRpIdError, + getWebAuthnRpId, + validateWebAuthnOptionsRpId, + validateWebAuthnRpId, +} from "./webauthn"; + +describe("WebAuthn RP ID validation", () => { + it("accepts an RP ID that matches the current hostname", () => { + expect( + validateWebAuthnRpId( + "rc-store.benhalverson.dev", + "rc-store.benhalverson.dev", + ), + ).toMatchObject({ isValid: true, rpId: "rc-store.benhalverson.dev" }); + }); + + it("accepts a parent-domain RP ID", () => { + expect( + validateWebAuthnRpId("benhalverson.dev", "rc-store.benhalverson.dev"), + ).toMatchObject({ isValid: true, rpId: "benhalverson.dev" }); + }); + + it("rejects localhost when the current host is deployed", () => { + expect( + validateWebAuthnRpId("localhost", "rc-store.benhalverson.dev"), + ).toMatchObject({ + isValid: false, + rpId: "localhost", + message: formatWebAuthnRpIdError({ + rpId: "localhost", + currentHostname: "rc-store.benhalverson.dev", + }), + }); + }); + + it("rejects URL-shaped RP IDs", () => { + expect( + validateWebAuthnRpId( + "https://rc-store.benhalverson.dev", + "rc-store.benhalverson.dev", + ), + ).toMatchObject({ + isValid: false, + rpId: "https://rc-store.benhalverson.dev", + }); + }); + + it("extracts RP ID from either auth or registration payloads", () => { + expect(getWebAuthnRpId({ rpId: "example.com" })).toBe("example.com"); + expect(getWebAuthnRpId({ rp: { id: "example.com" } })).toBe( + "example.com", + ); + }); + + it("validates nested registration RP IDs", () => { + expect( + validateWebAuthnOptionsRpId( + { rp: { id: "https://rc-store.benhalverson.dev" } }, + "rc-store.benhalverson.dev", + ), + ).toMatchObject({ + isValid: false, + rpId: "https://rc-store.benhalverson.dev", + }); + }); +}); \ No newline at end of file diff --git a/src/utils/webauthn.ts b/src/utils/webauthn.ts index e62a177..44290f7 100644 --- a/src/utils/webauthn.ts +++ b/src/utils/webauthn.ts @@ -16,3 +16,105 @@ export const bufferToBase64url = (buffer: ArrayBuffer): string => { .replace(/\//g, "_") .replace(/=/g, ""); }; + +interface WebAuthnRpEntity { + id?: string; +} + +interface WebAuthnRpIdCarrier { + rpId?: string; + rp?: WebAuthnRpEntity; +} + +interface WebAuthnRpIdValidationResult { + isValid: boolean; + message?: string; + rpId?: string; + currentHostname: string; +} + +const getCurrentHostname = () => + typeof window === "undefined" ? "" : window.location.hostname.toLowerCase(); + +const hasInvalidRpIdSyntax = (rpId: string) => + rpId.includes("://") || + rpId.includes("/") || + rpId.includes("?") || + rpId.includes("#") || + rpId.includes(":"); + +export const getWebAuthnRpId = ({ rpId, rp }: WebAuthnRpIdCarrier) => { + if (typeof rpId === "string" && rpId.trim()) { + return rpId.trim().toLowerCase(); + } + + if (typeof rp?.id === "string" && rp.id.trim()) { + return rp.id.trim().toLowerCase(); + } + + return undefined; +}; + +export const formatWebAuthnRpIdError = ({ + rpId, + currentHostname, +}: { + rpId: string; + currentHostname: string; +}) => + `Passkeys are unavailable on this deployment. The server returned RP ID "${rpId}" for "${currentHostname}". Expected the RP ID to match the current hostname or one of its parent domains.`; + +export const validateWebAuthnRpId = ( + rpId: string | undefined, + currentHostname = getCurrentHostname(), +): WebAuthnRpIdValidationResult => { + if (!rpId) { + return { isValid: true, currentHostname }; + } + + const normalizedRpId = rpId.trim().toLowerCase(); + const normalizedHostname = currentHostname.trim().toLowerCase(); + + if (!normalizedRpId) { + return { isValid: true, currentHostname: normalizedHostname }; + } + + if (hasInvalidRpIdSyntax(normalizedRpId)) { + return { + isValid: false, + rpId: normalizedRpId, + currentHostname: normalizedHostname, + message: formatWebAuthnRpIdError({ + rpId: normalizedRpId, + currentHostname: normalizedHostname, + }), + }; + } + + const matchesCurrentHost = + normalizedHostname === normalizedRpId || + normalizedHostname.endsWith(`.${normalizedRpId}`); + + if (!matchesCurrentHost) { + return { + isValid: false, + rpId: normalizedRpId, + currentHostname: normalizedHostname, + message: formatWebAuthnRpIdError({ + rpId: normalizedRpId, + currentHostname: normalizedHostname, + }), + }; + } + + return { + isValid: true, + rpId: normalizedRpId, + currentHostname: normalizedHostname, + }; +}; + +export const validateWebAuthnOptionsRpId = ( + options: WebAuthnRpIdCarrier, + currentHostname = getCurrentHostname(), +) => validateWebAuthnRpId(getWebAuthnRpId(options), currentHostname);