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