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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
25
packages/admin/src/lib/webauthn-environment.ts
Normal file
25
packages/admin/src/lib/webauthn-environment.ts
Normal 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();
|
||||
}
|
||||
54
packages/admin/tests/lib/webauthn-environment.test.ts
Normal file
54
packages/admin/tests/lib/webauthn-environment.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user