diff --git a/.changeset/reverse-proxy-passkey.md b/.changeset/reverse-proxy-passkey.md new file mode 100644 index 0000000..7ed7f7d --- /dev/null +++ b/.changeset/reverse-proxy-passkey.md @@ -0,0 +1,8 @@ +--- +"emdash": patch +"@emdash-cms/admin": patch +--- + +Adds `passkeyPublicOrigin` on `emdash()` so WebAuthn `origin` and `rpId` match the browser when dev sits behind a TLS-terminating reverse proxy. Validates the value at integration load time and threads it through all passkey-related API routes. + +Updates the admin passkey setup and login flows to detect non-secure origins and explain that passkeys need HTTPS or `http://localhost` rather than implying the browser lacks WebAuthn support. diff --git a/demos/simple/astro.config.mjs b/demos/simple/astro.config.mjs index e84e940..d775532 100644 --- a/demos/simple/astro.config.mjs +++ b/demos/simple/astro.config.mjs @@ -10,6 +10,13 @@ export default defineConfig({ adapter: node({ mode: "standalone", }), + // Example: allowed domains for reverse proxy + // security: { + // allowedDomains: [ + // { hostname: "emdash.local", protocol: "http" }, + // { hostname: "emdash.local", protocol: "https" }, + // ], + // }, image: { layout: "constrained", responsiveStyles: true, @@ -23,7 +30,15 @@ export default defineConfig({ baseUrl: "/_emdash/api/media/file", }), plugins: [auditLogPlugin()], + // HTTPS reverse proxy: uncomment so passkey verify matches browser origin + // passkeyPublicOrigin: "https://emdash.local:8443", }), ], devToolbar: { enabled: false }, + // Example: allowed hosts for reverse proxy + // vite: { + // server: { + // allowedHosts: ["emdash.local"], + // }, + // }, }); diff --git a/docs/src/content/docs/reference/configuration.mdx b/docs/src/content/docs/reference/configuration.mdx index dbe1e43..e1d5376 100644 --- a/docs/src/content/docs/reference/configuration.mdx +++ b/docs/src/content/docs/reference/configuration.mdx @@ -200,6 +200,67 @@ Use Cloudflare Access as the authentication provider instead of passkeys. magic links, and self-signup are disabled. +### `passkeyPublicOrigin` + +**Optional.** Pass a full **browser-facing origin** (scheme + host + optional port, **no path**) so WebAuthn **`rpId`** and **`origin`** match what the user’s browser sends in `clientData.origin`. + +By default, passkeys follow **`Astro.url`** / **`request.url`**. Behind a **TLS-terminating reverse proxy**, the app often still sees **`http://`** on the internal hop while the tab is **`https://`**, or the reconstructed host does not match the public name — which breaks passkey verification. Set `passkeyPublicOrigin` to the origin users type in the address bar (for example `https://cms.example.com` or `https://cms.example.com:8443`). + +The integration **validates** this value at load time: it must be a valid URL with **`http:`** or **`https:`** protocol and is normalized to **`origin`**. + +```js +emdash({ + database: sqlite({ url: "file:./data.db" }), + storage: local({ + directory: "./uploads", + baseUrl: "/_emdash/api/media/file", + }), + passkeyPublicOrigin: "https://cms.example.com", +}); +``` + +#### Reverse proxy and passkeys + +Astro only reflects **`X-Forwarded-*`** when the public host is allowed. Configure [**`security.allowedDomains`**](https://docs.astro.build/en/reference/configuration-reference/#securityalloweddomains) for the hostname (and schemes) your users hit. In **`astro dev`**, add matching **`vite.server.allowedHosts`** so Vite accepts the proxy **`Host`** header. + +Prefer fixing **`allowedDomains`** (and forwarded headers) first; use **`passkeyPublicOrigin`** when the reconstructed URL **still** diverges from the browser origin (typical when TLS is terminated in front and the upstream request stays **`http://`**). + +With TLS in front, binding the dev server to loopback (**`astro dev --host 127.0.0.1`**) is often enough: the proxy connects locally while **`passkeyPublicOrigin`** matches the public HTTPS origin. + + + +```js title="astro.config.mjs (excerpt)" +import { defineConfig } from "astro/config"; +import emdash, { local } from "emdash/astro"; +import { sqlite } from "emdash/db"; + +export default defineConfig({ + security: { + allowedDomains: [ + { hostname: "cms.example.com", protocol: "https" }, + { hostname: "cms.example.com", protocol: "http" }, + ], + }, + vite: { + server: { + allowedHosts: ["cms.example.com"], + }, + }, + integrations: [ + emdash({ + database: sqlite({ url: "file:./data.db" }), + storage: local({ + directory: "./uploads", + baseUrl: "/_emdash/api/media/file", + }), + passkeyPublicOrigin: "https://cms.example.com", + }), + ], +}); +``` + ## Database Adapters Import from `emdash/db`: diff --git a/e2e/fixture/emdash-env.d.ts b/e2e/fixture/emdash-env.d.ts index 55834d4..918ed35 100644 --- a/e2e/fixture/emdash-env.d.ts +++ b/e2e/fixture/emdash-env.d.ts @@ -3,7 +3,7 @@ /// -import type { PortableTextBlock } from "emdash"; +import type { ContentBylineCredit, PortableTextBlock } from "emdash"; export interface Page { id: string; @@ -14,6 +14,7 @@ export interface Page { createdAt: Date; updatedAt: Date; publishedAt: Date | null; + bylines?: ContentBylineCredit[]; } export interface Post { @@ -28,6 +29,7 @@ export interface Post { createdAt: Date; updatedAt: Date; publishedAt: Date | null; + bylines?: ContentBylineCredit[]; } declare module "emdash" { diff --git a/e2e/fixtures/refresh-server-pat.ts b/e2e/fixtures/refresh-server-pat.ts new file mode 100644 index 0000000..8fb56d0 --- /dev/null +++ b/e2e/fixtures/refresh-server-pat.ts @@ -0,0 +1,24 @@ +/** + * Re-runs dev-bypass after a dev-reset so the server info file has a valid PAT + * and the fixture database is back in "setup complete" state. + */ +import { readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const SERVER_INFO_PATH = join(tmpdir(), "emdash-pw-server.json"); + +export async function refreshServerPatAfterDevBypass(baseUrl: string): Promise { + const res = await fetch(`${baseUrl}/_emdash/api/setup/dev-bypass?token=1`); + if (!res.ok) { + throw new Error(`dev-bypass failed (${res.status}): ${await res.text()}`); + } + const json: { data: { token?: string } } = await res.json(); + const token = json.data.token; + if (!token) throw new Error("dev-bypass did not return a PAT token"); + + // Update the server info so subsequent tests use the fresh token + const info = JSON.parse(readFileSync(SERVER_INFO_PATH, "utf-8")); + info.token = token; + writeFileSync(SERVER_INFO_PATH, JSON.stringify(info, null, 2)); +} diff --git a/e2e/fixtures/virtual-authenticator.ts b/e2e/fixtures/virtual-authenticator.ts new file mode 100644 index 0000000..e3d6aae --- /dev/null +++ b/e2e/fixtures/virtual-authenticator.ts @@ -0,0 +1,33 @@ +/** + * Chrome DevTools virtual WebAuthn authenticator for passkey e2e. + * Chromium-only (CDP). See https://developer.chrome.com/docs/devtools/webauthn/ + */ +import type { Page } from "@playwright/test"; + +export async function addVirtualWebAuthnAuthenticator(page: Page): Promise<() => Promise> { + const session = await page.context().newCDPSession(page); + await session.send("WebAuthn.enable"); + const { authenticatorId } = await session.send("WebAuthn.addVirtualAuthenticator", { + options: { + protocol: "ctap2", + transport: "internal", + hasResidentKey: true, + hasUserVerification: true, + isUserVerified: true, + automaticPresenceSimulation: true, + }, + }); + + return async () => { + try { + await session.send("WebAuthn.removeVirtualAuthenticator", { authenticatorId }); + } catch { + // session may already be closed + } + try { + await session.detach(); + } catch { + // ignore + } + }; +} diff --git a/e2e/tests/passkey-full-setup-virtual-auth.spec.ts b/e2e/tests/passkey-full-setup-virtual-auth.spec.ts new file mode 100644 index 0000000..4a3e553 --- /dev/null +++ b/e2e/tests/passkey-full-setup-virtual-auth.spec.ts @@ -0,0 +1,75 @@ +/** + * End-to-end passkey registration with a CDP virtual authenticator (no human). + * Runs against the default fixture URL (http://localhost:4444). + * + * If this fails, the passkey stack (options → create → verify) is broken on localhost. + */ + +import { readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { expect, test } from "../fixtures"; +import { refreshServerPatAfterDevBypass } from "../fixtures/refresh-server-pat"; +import { addVirtualWebAuthnAuthenticator } from "../fixtures/virtual-authenticator"; + +const ADMIN_AFTER_SETUP_URL = /\/_emdash\/admin(\/login)?/; + +const SERVER_INFO_PATH = join(tmpdir(), "emdash-pw-server.json"); + +function fixtureBaseUrl(): string { + return JSON.parse(readFileSync(SERVER_INFO_PATH, "utf-8")).baseUrl as string; +} + +async function resetSetup(): Promise { + const base = fixtureBaseUrl(); + const res = await fetch(`${base}/_emdash/api/setup/dev-reset`, { + method: "POST", + headers: { "X-EmDash-Request": "1", Origin: base }, + }); + if (!res.ok) throw new Error(`dev-reset failed: ${res.status}`); +} + +async function restoreFixtureSetup(): Promise { + await refreshServerPatAfterDevBypass(fixtureBaseUrl()); +} + +test.describe("Setup wizard passkey with virtual authenticator (localhost)", () => { + test.describe.configure({ mode: "serial" }); + + test.beforeEach(async () => { + await resetSetup(); + }); + + test.afterAll(async () => { + await restoreFixtureSetup(); + }); + + test("completes full setup including passkey registration", async ({ admin, page }) => { + test.setTimeout(120_000); + const removeAuth = await addVirtualWebAuthnAuthenticator(page); + + try { + await admin.goToSetup(); + + await page.getByLabel("Site Title").fill("Virtual Auth Site"); + await page.getByRole("button", { name: "Continue" }).click(); + await expect(page.locator("text=Create your account")).toBeVisible(); + + await page.getByLabel("Your Email").fill("virtual-auth@example.com"); + await page.getByLabel("Your Name").fill("Virtual Auth User"); + await page.getByRole("button", { name: "Continue" }).click(); + + await expect(page.locator("text=Set up your passkey")).toBeVisible(); + await page.getByRole("button", { name: "Create Passkey" }).click(); + + // admin-verify creates the user but does not set a session; wizard sends user to /_emdash/admin and auth redirects to login. + await expect(page).toHaveURL(ADMIN_AFTER_SETUP_URL, { timeout: 60_000 }); + await expect(page.locator("text=Set up your passkey")).toHaveCount(0); + await expect(page.locator("text=Registration was cancelled or timed out")).toHaveCount(0); + await expect(page.locator("text=Invalid origin")).toHaveCount(0); + } finally { + await removeAuth(); + } + }); +}); diff --git a/e2e/tests/setup-wizard.spec.ts b/e2e/tests/setup-wizard.spec.ts index 4f6188a..e007a8b 100644 --- a/e2e/tests/setup-wizard.spec.ts +++ b/e2e/tests/setup-wizard.spec.ts @@ -11,6 +11,7 @@ */ import { test, expect } from "../fixtures"; +import { refreshServerPatAfterDevBypass } from "../fixtures/refresh-server-pat"; const BASE_URL = "http://localhost:4444"; const ADMIN_DASHBOARD_PATTERN = /\/_emdash\/admin\/?$/; @@ -26,10 +27,7 @@ async function resetSetup(): Promise { } async function restoreSetup(): Promise { - const res = await fetch(`${BASE_URL}/_emdash/api/setup/dev-bypass?token=1`); - if (!res.ok) { - throw new Error(`dev-bypass failed (${res.status}): ${await res.text()}`); - } + await refreshServerPatAfterDevBypass(BASE_URL); } test.describe("Setup Wizard", () => { diff --git a/packages/admin/src/components/auth/PasskeyLogin.tsx b/packages/admin/src/components/auth/PasskeyLogin.tsx index 168fc66..a5a5937 100644 --- a/packages/admin/src/components/auth/PasskeyLogin.tsx +++ b/packages/admin/src/components/auth/PasskeyLogin.tsx @@ -15,6 +15,10 @@ import { Button, Input } from "@cloudflare/kumo"; import * as React from "react"; import { apiFetch, parseApiResponse } from "../../lib/api/client"; +import { + isPasskeyEnvironmentUsable, + isWebAuthnSecureContext, +} from "../../lib/webauthn-environment"; // ============================================================================ // Constants @@ -74,22 +78,11 @@ type LoginState = | { status: "error"; message: string } | { status: "success" }; -/** - * Check if WebAuthn is supported in the current browser - */ -function isWebAuthnSupported(): boolean { - return ( - typeof window !== "undefined" && - window.PublicKeyCredential !== undefined && - typeof window.PublicKeyCredential === "function" - ); -} - /** * Check if conditional mediation (autofill) is supported */ async function isConditionalMediationSupported(): Promise { - if (!isWebAuthnSupported()) return false; + if (!isPasskeyEnvironmentUsable()) return false; try { return (await PublicKeyCredential.isConditionalMediationAvailable?.()) ?? false; } catch { @@ -142,8 +135,11 @@ export function PasskeyLogin({ const [email, setEmail] = React.useState(""); const [supportsConditional, setSupportsConditional] = React.useState(false); - // Check WebAuthn support on mount - const isSupported = React.useMemo(() => isWebAuthnSupported(), []); + const isSupported = React.useMemo(() => isPasskeyEnvironmentUsable(), []); + const insecureContext = React.useMemo( + () => typeof window !== "undefined" && !isWebAuthnSecureContext(), + [], + ); // Check conditional mediation support React.useEffect(() => { @@ -155,7 +151,9 @@ export function PasskeyLogin({ if (!isSupported) { setState({ status: "error", - message: "WebAuthn is not supported in this browser", + message: insecureContext + ? "Passkeys require HTTPS or http://localhost (with your port); this hostname is not a secure browser context." + : "WebAuthn is not supported in this browser", }); return; } @@ -280,17 +278,37 @@ export function PasskeyLogin({ onError?.(new Error(userMessage)); } }, - [isSupported, optionsEndpoint, verifyEndpoint, email, supportsConditional, onSuccess, onError], + [ + isSupported, + insecureContext, + optionsEndpoint, + verifyEndpoint, + email, + supportsConditional, + onSuccess, + onError, + ], ); - // Not supported message if (!isSupported) { return (
-

Passkeys Not Supported

+

Passkeys Not Available Here

- Your browser doesn't support passkeys. Please use a modern browser like Chrome, Safari, - Firefox, or Edge. + {insecureContext ? ( + <> + Passkeys require a secure context: use{" "} + HTTPS, or open the admin at{" "} + http://localhost (with your dev port). + Plain http:// on a custom hostname is not treated as + secure, even on loopback. + + ) : ( + <> + Your browser doesn't support passkeys. Please use a modern browser like Chrome, + Safari, Firefox, or Edge. + + )}

); diff --git a/packages/admin/src/components/auth/PasskeyRegistration.tsx b/packages/admin/src/components/auth/PasskeyRegistration.tsx index e79b525..48cc607 100644 --- a/packages/admin/src/components/auth/PasskeyRegistration.tsx +++ b/packages/admin/src/components/auth/PasskeyRegistration.tsx @@ -15,6 +15,10 @@ import { Button, Input } from "@cloudflare/kumo"; import * as React from "react"; import { apiFetch, parseApiResponse } from "../../lib/api/client"; +import { + isPasskeyEnvironmentUsable, + isWebAuthnSecureContext, +} from "../../lib/webauthn-environment"; // ============================================================================ // Constants @@ -95,17 +99,6 @@ type RegistrationState = | { status: "error"; message: string } | { status: "success" }; -/** - * Check if WebAuthn is supported in the current browser - */ -function isWebAuthnSupported(): boolean { - return ( - typeof window !== "undefined" && - window.PublicKeyCredential !== undefined && - typeof window.PublicKeyCredential === "function" - ); -} - /** * Convert base64url to ArrayBuffer */ @@ -153,8 +146,12 @@ export function PasskeyRegistration({ }); const [passkeyName, setPasskeyName] = React.useState(""); - // Check WebAuthn support on mount - const isSupported = React.useMemo(() => isWebAuthnSupported(), []); + // Secure context (HTTPS or http://localhost) + PublicKeyCredential + const isSupported = React.useMemo(() => isPasskeyEnvironmentUsable(), []); + const insecureContext = React.useMemo( + () => typeof window !== "undefined" && !isWebAuthnSecureContext(), + [], + ); const handleRegister = React.useCallback(async () => { if (!isSupported) { @@ -292,14 +289,26 @@ export function PasskeyRegistration({ onError, ]); - // Not supported message + // Not usable (insecure origin vs missing API — browser hides WebAuthn the same way) if (!isSupported) { return (
-

Passkeys Not Supported

+

Passkeys Not Available Here

- Your browser doesn't support passkeys. Please use a modern browser like Chrome, Safari, - Firefox, or Edge. + {insecureContext ? ( + <> + Passkeys require a secure context: use{" "} + HTTPS, or open the admin at{" "} + http://localhost (with your dev port). + Plain http:// on a custom hostname is not treated as + secure, even on loopback. + + ) : ( + <> + Your browser doesn't support passkeys. Please use a modern browser like Chrome, + Safari, Firefox, or Edge. + + )}

); diff --git a/packages/admin/src/lib/webauthn-environment.ts b/packages/admin/src/lib/webauthn-environment.ts new file mode 100644 index 0000000..60f13f5 --- /dev/null +++ b/packages/admin/src/lib/webauthn-environment.ts @@ -0,0 +1,25 @@ +/** + * WebAuthn is only available in a browser "secure context": HTTPS, or special-cased + * loopback hosts such as `http://localhost` / `http://127.0.0.1`. + * + * An origin like `http://emdash.local:8081` resolves to 127.0.0.1 but is still + * **not** a secure context, so `PublicKeyCredential` is hidden — the same symptom + * as an unsupported browser. + */ + +export function isWebAuthnSecureContext(): boolean { + return typeof window !== "undefined" && window.isSecureContext; +} + +export function isPublicKeyCredentialConstructorAvailable(): boolean { + return ( + typeof window !== "undefined" && + window.PublicKeyCredential !== undefined && + typeof window.PublicKeyCredential === "function" + ); +} + +/** True when the page can use `navigator.credentials` for passkeys. */ +export function isPasskeyEnvironmentUsable(): boolean { + return isWebAuthnSecureContext() && isPublicKeyCredentialConstructorAvailable(); +} diff --git a/packages/admin/tests/lib/webauthn-environment.test.ts b/packages/admin/tests/lib/webauthn-environment.test.ts new file mode 100644 index 0000000..e369e47 --- /dev/null +++ b/packages/admin/tests/lib/webauthn-environment.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect, afterEach } from "vitest"; + +import { + isPasskeyEnvironmentUsable, + isPublicKeyCredentialConstructorAvailable, + isWebAuthnSecureContext, +} from "../../src/lib/webauthn-environment"; + +describe("webauthn-environment", () => { + const origPk = globalThis.window.PublicKeyCredential; + const desc = Object.getOwnPropertyDescriptor(globalThis.window, "isSecureContext"); + + afterEach(() => { + if (origPk === undefined) { + delete (globalThis.window as { PublicKeyCredential?: unknown }).PublicKeyCredential; + } else { + Object.defineProperty(globalThis.window, "PublicKeyCredential", { + value: origPk, + configurable: true, + writable: true, + }); + } + if (desc) Object.defineProperty(globalThis.window, "isSecureContext", desc); + }); + + it("is usable only when secure context and PublicKeyCredential constructor exist", () => { + Object.defineProperty(globalThis.window, "isSecureContext", { + value: true, + configurable: true, + }); + Object.defineProperty(globalThis.window, "PublicKeyCredential", { + value: function PublicKeyCredential() {}, + configurable: true, + writable: true, + }); + expect(isWebAuthnSecureContext()).toBe(true); + expect(isPublicKeyCredentialConstructorAvailable()).toBe(true); + expect(isPasskeyEnvironmentUsable()).toBe(true); + }); + + it("is not usable in an insecure context even if PublicKeyCredential is defined", () => { + Object.defineProperty(globalThis.window, "isSecureContext", { + value: false, + configurable: true, + }); + Object.defineProperty(globalThis.window, "PublicKeyCredential", { + value: function PublicKeyCredential() {}, + configurable: true, + writable: true, + }); + expect(isWebAuthnSecureContext()).toBe(false); + expect(isPasskeyEnvironmentUsable()).toBe(false); + }); +}); diff --git a/packages/core/src/astro/integration/index.ts b/packages/core/src/astro/integration/index.ts index 14b56d6..3ed69a2 100644 --- a/packages/core/src/astro/integration/index.ts +++ b/packages/core/src/astro/integration/index.ts @@ -90,6 +90,22 @@ export function emdash(config: EmDashConfig = {}): AstroIntegration { } } + if (resolvedConfig.passkeyPublicOrigin) { + const raw = resolvedConfig.passkeyPublicOrigin; + try { + const parsed = new URL(raw); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error(`passkeyPublicOrigin must be http or https (got ${parsed.protocol})`); + } + resolvedConfig.passkeyPublicOrigin = parsed.origin; + } catch (e) { + if (e instanceof TypeError) { + throw new Error(`Invalid passkeyPublicOrigin: "${raw}"`, { cause: e }); + } + throw e; + } + } + // Plugin descriptors from config const pluginDescriptors = resolvedConfig.plugins ?? []; const sandboxedDescriptors = resolvedConfig.sandboxed ?? []; @@ -136,6 +152,7 @@ export function emdash(config: EmDashConfig = {}): AstroIntegration { storage: resolvedConfig.storage, auth: resolvedConfig.auth, marketplace: resolvedConfig.marketplace, + passkeyPublicOrigin: resolvedConfig.passkeyPublicOrigin, }; // Determine auth mode for route injection diff --git a/packages/core/src/astro/integration/runtime.ts b/packages/core/src/astro/integration/runtime.ts index 63efaef..7b000d7 100644 --- a/packages/core/src/astro/integration/runtime.ts +++ b/packages/core/src/astro/integration/runtime.ts @@ -262,6 +262,19 @@ export interface EmDashConfig { */ marketplace?: string; + /** + * Public browser origin for passkey verification (rpId + expected WebAuthn origin). + * + * Use when `Astro.url` / `request.url` do not match what users open — common with a + * **TLS-terminating reverse proxy**: the app often sees `http://` on the internal hop + * while the browser uses `https://`, which breaks WebAuthn origin checks. + * + * Set to the full origin users type in the address bar (no path), e.g. + * `https://emdash.local:8443`. Prefer fixing `security.allowedDomains` for the proxy first; + * use this when the reconstructed URL still diverges from the browser. + */ + passkeyPublicOrigin?: string; + /** * Enable playground mode for ephemeral "try EmDash" sites. * diff --git a/packages/core/src/astro/routes/api/auth/invite/complete.ts b/packages/core/src/astro/routes/api/auth/invite/complete.ts index 9a49822..8333afb 100644 --- a/packages/core/src/astro/routes/api/auth/invite/complete.ts +++ b/packages/core/src/astro/routes/api/auth/invite/complete.ts @@ -22,6 +22,7 @@ import { OptionsRepository } from "#db/repositories/options.js"; export const POST: APIRoute = async ({ request, locals, session }) => { const { emdash } = locals; + const passkeyPublicOrigin = emdash?.config.passkeyPublicOrigin; if (!emdash?.db) { return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); @@ -37,7 +38,7 @@ export const POST: APIRoute = async ({ request, locals, session }) => { const url = new URL(request.url); const options = new OptionsRepository(emdash.db); const siteName = (await options.get("emdash:site_title")) ?? undefined; - const passkeyConfig = getPasskeyConfig(url, siteName); + const passkeyConfig = getPasskeyConfig(url, siteName, passkeyPublicOrigin); // Verify the passkey registration response const challengeStore = createChallengeStore(emdash.db); diff --git a/packages/core/src/astro/routes/api/auth/passkey/options.ts b/packages/core/src/astro/routes/api/auth/passkey/options.ts index ba8e69f..51fb533 100644 --- a/packages/core/src/astro/routes/api/auth/passkey/options.ts +++ b/packages/core/src/astro/routes/api/auth/passkey/options.ts @@ -23,6 +23,7 @@ import { OptionsRepository } from "#db/repositories/options.js"; export const POST: APIRoute = async ({ request, locals }) => { const { emdash } = locals; + const passkeyPublicOrigin = emdash?.config.passkeyPublicOrigin; if (!emdash?.db) { return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); @@ -62,7 +63,7 @@ export const POST: APIRoute = async ({ request, locals }) => { const url = new URL(request.url); const options = new OptionsRepository(emdash.db); const siteName = (await options.get("emdash:site_title")) ?? undefined; - const passkeyConfig = getPasskeyConfig(url, siteName); + const passkeyConfig = getPasskeyConfig(url, siteName, passkeyPublicOrigin); // Generate authentication options const challengeStore = createChallengeStore(emdash.db); diff --git a/packages/core/src/astro/routes/api/auth/passkey/register/options.ts b/packages/core/src/astro/routes/api/auth/passkey/register/options.ts index 38256db..b4b2bef 100644 --- a/packages/core/src/astro/routes/api/auth/passkey/register/options.ts +++ b/packages/core/src/astro/routes/api/auth/passkey/register/options.ts @@ -22,6 +22,7 @@ const MAX_PASSKEYS = 10; export const POST: APIRoute = async ({ request, locals }) => { const { emdash, user } = locals; + const passkeyPublicOrigin = emdash?.config.passkeyPublicOrigin; if (!emdash?.db) { return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); @@ -52,7 +53,7 @@ export const POST: APIRoute = async ({ request, locals }) => { const url = new URL(request.url); const optionsRepo = new OptionsRepository(emdash.db); const siteName = (await optionsRepo.get("emdash:site_title")) ?? undefined; - const passkeyConfig = getPasskeyConfig(url, siteName); + const passkeyConfig = getPasskeyConfig(url, siteName, passkeyPublicOrigin); // Generate registration options const challengeStore = createChallengeStore(emdash.db); diff --git a/packages/core/src/astro/routes/api/auth/passkey/register/verify.ts b/packages/core/src/astro/routes/api/auth/passkey/register/verify.ts index 8270988..56eb123 100644 --- a/packages/core/src/astro/routes/api/auth/passkey/register/verify.ts +++ b/packages/core/src/astro/routes/api/auth/passkey/register/verify.ts @@ -31,6 +31,7 @@ interface PasskeyResponse { export const POST: APIRoute = async ({ request, locals }) => { const { emdash, user } = locals; + const passkeyPublicOrigin = emdash?.config.passkeyPublicOrigin; if (!emdash?.db) { return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); @@ -58,7 +59,7 @@ export const POST: APIRoute = async ({ request, locals }) => { const url = new URL(request.url); const optionsRepo = new OptionsRepository(emdash.db); const siteName = (await optionsRepo.get("emdash:site_title")) ?? undefined; - const passkeyConfig = getPasskeyConfig(url, siteName); + const passkeyConfig = getPasskeyConfig(url, siteName, passkeyPublicOrigin); // Verify the registration response const challengeStore = createChallengeStore(emdash.db); diff --git a/packages/core/src/astro/routes/api/auth/passkey/verify.ts b/packages/core/src/astro/routes/api/auth/passkey/verify.ts index f8e0061..f8949bf 100644 --- a/packages/core/src/astro/routes/api/auth/passkey/verify.ts +++ b/packages/core/src/astro/routes/api/auth/passkey/verify.ts @@ -20,6 +20,7 @@ import { OptionsRepository } from "#db/repositories/options.js"; export const POST: APIRoute = async ({ request, locals, session }) => { const { emdash } = locals; + const passkeyPublicOrigin = emdash?.config.passkeyPublicOrigin; if (!emdash?.db) { return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); @@ -33,7 +34,7 @@ export const POST: APIRoute = async ({ request, locals, session }) => { const url = new URL(request.url); const options = new OptionsRepository(emdash.db); const siteName = (await options.get("emdash:site_title")) ?? undefined; - const passkeyConfig = getPasskeyConfig(url, siteName); + const passkeyConfig = getPasskeyConfig(url, siteName, passkeyPublicOrigin); // Authenticate with passkey const adapter = createKyselyAdapter(emdash.db); diff --git a/packages/core/src/astro/routes/api/auth/signup/complete.ts b/packages/core/src/astro/routes/api/auth/signup/complete.ts index 1ebcf5b..1af7d67 100644 --- a/packages/core/src/astro/routes/api/auth/signup/complete.ts +++ b/packages/core/src/astro/routes/api/auth/signup/complete.ts @@ -22,6 +22,7 @@ import { OptionsRepository } from "#db/repositories/options.js"; export const POST: APIRoute = async ({ request, locals, session }) => { const { emdash } = locals; + const passkeyPublicOrigin = emdash?.config.passkeyPublicOrigin; if (!emdash?.db) { return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); @@ -37,7 +38,7 @@ export const POST: APIRoute = async ({ request, locals, session }) => { const url = new URL(request.url); const options = new OptionsRepository(emdash.db); const siteName = (await options.get("emdash:site_title")) ?? undefined; - const passkeyConfig = getPasskeyConfig(url, siteName); + const passkeyConfig = getPasskeyConfig(url, siteName, passkeyPublicOrigin); // Verify the passkey registration response const challengeStore = createChallengeStore(emdash.db); diff --git a/packages/core/src/astro/routes/api/setup/admin-verify.ts b/packages/core/src/astro/routes/api/setup/admin-verify.ts index 0162a49..ca9bd74 100644 --- a/packages/core/src/astro/routes/api/setup/admin-verify.ts +++ b/packages/core/src/astro/routes/api/setup/admin-verify.ts @@ -21,6 +21,7 @@ import { OptionsRepository } from "#db/repositories/options.js"; export const POST: APIRoute = async ({ request, locals }) => { const { emdash } = locals; + const passkeyPublicOrigin = emdash?.config.passkeyPublicOrigin; if (!emdash?.db) { return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); @@ -57,7 +58,7 @@ export const POST: APIRoute = async ({ request, locals }) => { // Get passkey config const url = new URL(request.url); const siteName = (await options.get("emdash:site_title")) ?? undefined; - const passkeyConfig = getPasskeyConfig(url, siteName); + const passkeyConfig = getPasskeyConfig(url, siteName, passkeyPublicOrigin); // Verify the registration response const challengeStore = createChallengeStore(emdash.db); diff --git a/packages/core/src/astro/routes/api/setup/admin.ts b/packages/core/src/astro/routes/api/setup/admin.ts index 90447ea..fe091cb 100644 --- a/packages/core/src/astro/routes/api/setup/admin.ts +++ b/packages/core/src/astro/routes/api/setup/admin.ts @@ -20,6 +20,7 @@ import { OptionsRepository } from "#db/repositories/options.js"; export const POST: APIRoute = async ({ request, locals }) => { const { emdash } = locals; + const passkeyPublicOrigin = emdash?.config.passkeyPublicOrigin; if (!emdash?.db) { return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); @@ -56,7 +57,7 @@ export const POST: APIRoute = async ({ request, locals }) => { // Get passkey config const url = new URL(request.url); const siteName = (await options.get("emdash:site_title")) ?? undefined; - const passkeyConfig = getPasskeyConfig(url, siteName); + const passkeyConfig = getPasskeyConfig(url, siteName, passkeyPublicOrigin); // Generate registration options const challengeStore = createChallengeStore(emdash.db); diff --git a/packages/core/src/auth/passkey-config.ts b/packages/core/src/auth/passkey-config.ts index 1ece1d0..d373916 100644 --- a/packages/core/src/auth/passkey-config.ts +++ b/packages/core/src/auth/passkey-config.ts @@ -15,10 +15,31 @@ export interface PasskeyConfig { /** * Get passkey configuration from request URL * - * @param url The request URL - * @param siteName Optional site name for rpName (defaults to hostname) + * @param url The request URL (typically `new URL(Astro.request.url)` or `new URL(request.url)`) + * @param siteName Optional site name for rpName (defaults to hostname from `url` or public origin) + * @param passkeyPublicOrigin Optional browser-facing origin (see `EmDashConfig.passkeyPublicOrigin`). + * When set, **origin** and **rpId** are taken from this URL so they match WebAuthn `clientData.origin`. + * @throws If `passkeyPublicOrigin` is non-empty but not parseable by `new URL()`. */ -export function getPasskeyConfig(url: URL, siteName?: string): PasskeyConfig { +export function getPasskeyConfig( + url: URL, + siteName?: string, + passkeyPublicOrigin?: string, +): PasskeyConfig { + if (passkeyPublicOrigin) { + let publicUrl: URL; + try { + publicUrl = new URL(passkeyPublicOrigin); + } catch (e) { + throw new Error(`Invalid passkeyPublicOrigin: "${passkeyPublicOrigin}"`, { cause: e }); + } + return { + rpName: siteName || publicUrl.hostname, + rpId: publicUrl.hostname, + origin: publicUrl.origin, + }; + } + return { rpName: siteName || url.hostname, rpId: url.hostname, diff --git a/packages/core/tests/unit/auth/passkey-config.test.ts b/packages/core/tests/unit/auth/passkey-config.test.ts index f9d1009..bf942fb 100644 --- a/packages/core/tests/unit/auth/passkey-config.test.ts +++ b/packages/core/tests/unit/auth/passkey-config.test.ts @@ -2,8 +2,54 @@ import { describe, it, expect } from "vitest"; import { getPasskeyConfig } from "../../../src/auth/passkey-config.js"; +/** URL shape from `new URL(request.url)` after trusted proxy + Astro `security.allowedDomains`. */ +function urlAfterTrustedProxy(path: string, host: string, proto: "http" | "https"): URL { + return new URL(path, `${proto}://${host}`); +} + describe("passkey-config", () => { + describe("getPasskeyConfig() via emulated reverse proxy URL", () => { + const internalDevUrl = "http://127.0.0.1:4321/_emdash/api/auth/passkey/register/options"; + + it("loopback URL alone matches Node before rewrite — rpId is not the public host", () => { + const url = new URL(internalDevUrl); + expect(getPasskeyConfig(url).rpId).toBe("127.0.0.1"); + }); + + it("forwarded Host/Proto yield the URL handlers see; rp matches HTTP reverse-proxy edge", () => { + const url = urlAfterTrustedProxy( + "/_emdash/api/auth/passkey/register/options", + "emdash.local:8080", + "http", + ); + const config = getPasskeyConfig(url, "My Site"); + expect(config.rpId).toBe("emdash.local"); + expect(config.rpName).toBe("My Site"); + expect(config.origin).toBe("http://emdash.local:8080"); + }); + + it("HTTPS listener on proxy with HTTP upstream: passkeyPublicOrigin aligns origin with browser", () => { + const urlAstroSeesFromForwardedHttp = urlAfterTrustedProxy( + "/_emdash/api/setup/admin", + "emdash.local:8080", + "http", + ); + const browserOrigin = "https://emdash.local:8443"; + const config = getPasskeyConfig(urlAstroSeesFromForwardedHttp, "My Site", browserOrigin); + expect(config.rpId).toBe("emdash.local"); + expect(config.rpName).toBe("My Site"); + expect(config.origin).toBe(browserOrigin); + }); + }); + describe("getPasskeyConfig()", () => { + it("throws when passkeyPublicOrigin is not a valid URL", () => { + const url = new URL("http://localhost:4321/admin"); + expect(() => getPasskeyConfig(url, "Site", "::not-a-url")).toThrow( + "Invalid passkeyPublicOrigin", + ); + }); + it("extracts rpId from localhost URL", () => { const url = new URL("http://localhost:4321/admin"); const config = getPasskeyConfig(url); @@ -78,5 +124,47 @@ describe("passkey-config", () => { expect(config.origin).toBe("https://example.com"); expect(config.rpId).toBe("example.com"); }); + + it("documents HTTPS reverse-proxy dev pitfall: server URL scheme must match the browser", () => { + const serverDevUrl = new URL("http://emdash.local:8443/_emdash/api/setup/admin"); + const browserPageOrigin = new URL("https://emdash.local:8443/_emdash/admin/setup"); + + const fromServer = getPasskeyConfig(serverDevUrl); + const fromBrowser = getPasskeyConfig(browserPageOrigin); + + expect(fromServer.rpId).toBe(fromBrowser.rpId); + expect(fromServer.origin).toBe("http://emdash.local:8443"); + expect(fromBrowser.origin).toBe("https://emdash.local:8443"); + // verifyRegistrationResponse requires clientData.origin === config.origin (see @emdash-cms/auth/passkey) + expect(fromServer.origin).not.toBe(fromBrowser.origin); + }); + + it("passkeyPublicOrigin overrides origin and rpId (TLS termination and loopback request URL)", () => { + const fromForwardedHttp = getPasskeyConfig( + new URL("http://emdash.local:8443/_emdash/api/setup/admin"), + "My Site", + "https://emdash.local:8443", + ); + expect(fromForwardedHttp.rpName).toBe("My Site"); + expect(fromForwardedHttp.rpId).toBe("emdash.local"); + expect(fromForwardedHttp.origin).toBe("https://emdash.local:8443"); + + const fromLoopback = getPasskeyConfig( + new URL("http://127.0.0.1:4321/_emdash/api/setup/admin"), + "My CMS", + "https://public.example:8443", + ); + expect(fromLoopback.rpId).toBe("public.example"); + expect(fromLoopback.rpName).toBe("My CMS"); + expect(fromLoopback.origin).toBe("https://public.example:8443"); + + const hostnameOnly = getPasskeyConfig( + new URL("http://127.0.0.1:4321/x"), + undefined, + "https://public.example:8443", + ); + expect(hostnameOnly.rpName).toBe("public.example"); + expect(hostnameOnly.rpId).toBe("public.example"); + }); }); }); diff --git a/skills/building-emdash-site/references/configuration.md b/skills/building-emdash-site/references/configuration.md index d24f221..e862806 100644 --- a/skills/building-emdash-site/references/configuration.md +++ b/skills/building-emdash-site/references/configuration.md @@ -32,6 +32,10 @@ export default defineConfig({ }); ``` +### Reverse proxy and passkeys + +Passkey `rpId` / `origin` follow Astro `context.url`, which only reflects `X-Forwarded-*` when you declare **allowed public hosts** ([`security.allowedDomains`](https://docs.astro.build/en/reference/configuration-reference/#securityalloweddomains)). In dev, add matching **`vite.server.allowedHosts`** or Vite rejects the proxy `Host`. Use **`emdash({ passkeyPublicOrigin: "https://…" })`** when the browser origin and reconstructed URL still disagree (common with TLS termination). With TLS terminated in front, **`astro dev --host 127.0.0.1`** (loopback) is usually enough: the proxy reaches the dev server locally while **`passkeyPublicOrigin`** matches the browser’s HTTPS origin—without opening the Node port on the LAN. + ### Cloudflare (D1 + R2) ```javascript