fix: passkeys behind TLS reverse proxy (#225)

* fix: passkeys behind TLS reverse proxy

Add passkeyPublicOrigin and wire it through passkey routes so origin/rpId match
the browser when dev runs behind nginx. Expose dev-only /_emdash/api/dev/passkey-url,
add admin messaging for insecure WebAuthn contexts, nginx repro under demos/simple,
and direct kysely dependency for the simple demo Node adapter bundle.

Made-with: Cursor

* docs: add passkeyPublicOrigin to configuration reference

Adds the new passkeyPublicOrigin option and reverse proxy guidance
to the public-facing configuration docs as requested in PR review.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* update tests and more docs

* fix: add missing refresh-server-pat fixture and restore docs heading

---------

Co-authored-by: Joseph Eftekhari <jdeftekhari@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
seslly
2026-04-05 23:41:07 -07:00
committed by GitHub
parent 28f98fda4f
commit d2114523a5
25 changed files with 526 additions and 53 deletions

View File

@@ -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<boolean> {
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 (
<div className="rounded-lg border border-kumo-danger/50 bg-kumo-danger/10 p-4">
<h3 className="font-medium text-kumo-danger">Passkeys Not Supported</h3>
<h3 className="font-medium text-kumo-danger">Passkeys Not Available Here</h3>
<p className="mt-1 text-sm text-kumo-subtle">
Your browser doesn't support passkeys. Please use a modern browser like Chrome, Safari,
Firefox, or Edge.
{insecureContext ? (
<>
Passkeys require a <strong className="text-kumo-default">secure context</strong>: use{" "}
<strong className="text-kumo-default">HTTPS</strong>, or open the admin at{" "}
<strong className="text-kumo-default">http://localhost</strong> (with your dev port).
Plain <code className="text-xs">http://</code> 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.
</>
)}
</p>
</div>
);

View File

@@ -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 (
<div className="rounded-lg border border-kumo-danger/50 bg-kumo-danger/10 p-4">
<h3 className="font-medium text-kumo-danger">Passkeys Not Supported</h3>
<h3 className="font-medium text-kumo-danger">Passkeys Not Available Here</h3>
<p className="mt-1 text-sm text-kumo-subtle">
Your browser doesn't support passkeys. Please use a modern browser like Chrome, Safari,
Firefox, or Edge.
{insecureContext ? (
<>
Passkeys require a <strong className="text-kumo-default">secure context</strong>: use{" "}
<strong className="text-kumo-default">HTTPS</strong>, or open the admin at{" "}
<strong className="text-kumo-default">http://localhost</strong> (with your dev port).
Plain <code className="text-xs">http://</code> 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.
</>
)}
</p>
</div>
);

View File

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

View File

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

View File

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

View File

@@ -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.
*

View File

@@ -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<string>("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);

View File

@@ -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<string>("emdash:site_title")) ?? undefined;
const passkeyConfig = getPasskeyConfig(url, siteName);
const passkeyConfig = getPasskeyConfig(url, siteName, passkeyPublicOrigin);
// Generate authentication options
const challengeStore = createChallengeStore(emdash.db);

View File

@@ -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<string>("emdash:site_title")) ?? undefined;
const passkeyConfig = getPasskeyConfig(url, siteName);
const passkeyConfig = getPasskeyConfig(url, siteName, passkeyPublicOrigin);
// Generate registration options
const challengeStore = createChallengeStore(emdash.db);

View File

@@ -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<string>("emdash:site_title")) ?? undefined;
const passkeyConfig = getPasskeyConfig(url, siteName);
const passkeyConfig = getPasskeyConfig(url, siteName, passkeyPublicOrigin);
// Verify the registration response
const challengeStore = createChallengeStore(emdash.db);

View File

@@ -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<string>("emdash:site_title")) ?? undefined;
const passkeyConfig = getPasskeyConfig(url, siteName);
const passkeyConfig = getPasskeyConfig(url, siteName, passkeyPublicOrigin);
// Authenticate with passkey
const adapter = createKyselyAdapter(emdash.db);

View File

@@ -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<string>("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);

View File

@@ -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<string>("emdash:site_title")) ?? undefined;
const passkeyConfig = getPasskeyConfig(url, siteName);
const passkeyConfig = getPasskeyConfig(url, siteName, passkeyPublicOrigin);
// Verify the registration response
const challengeStore = createChallengeStore(emdash.db);

View File

@@ -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<string>("emdash:site_title")) ?? undefined;
const passkeyConfig = getPasskeyConfig(url, siteName);
const passkeyConfig = getPasskeyConfig(url, siteName, passkeyPublicOrigin);
// Generate registration options
const challengeStore = createChallengeStore(emdash.db);

View File

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

View File

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