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:
8
.changeset/reverse-proxy-passkey.md
Normal file
8
.changeset/reverse-proxy-passkey.md
Normal file
@@ -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.
|
||||||
@@ -10,6 +10,13 @@ export default defineConfig({
|
|||||||
adapter: node({
|
adapter: node({
|
||||||
mode: "standalone",
|
mode: "standalone",
|
||||||
}),
|
}),
|
||||||
|
// Example: allowed domains for reverse proxy
|
||||||
|
// security: {
|
||||||
|
// allowedDomains: [
|
||||||
|
// { hostname: "emdash.local", protocol: "http" },
|
||||||
|
// { hostname: "emdash.local", protocol: "https" },
|
||||||
|
// ],
|
||||||
|
// },
|
||||||
image: {
|
image: {
|
||||||
layout: "constrained",
|
layout: "constrained",
|
||||||
responsiveStyles: true,
|
responsiveStyles: true,
|
||||||
@@ -23,7 +30,15 @@ export default defineConfig({
|
|||||||
baseUrl: "/_emdash/api/media/file",
|
baseUrl: "/_emdash/api/media/file",
|
||||||
}),
|
}),
|
||||||
plugins: [auditLogPlugin()],
|
plugins: [auditLogPlugin()],
|
||||||
|
// HTTPS reverse proxy: uncomment so passkey verify matches browser origin
|
||||||
|
// passkeyPublicOrigin: "https://emdash.local:8443",
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
devToolbar: { enabled: false },
|
devToolbar: { enabled: false },
|
||||||
|
// Example: allowed hosts for reverse proxy
|
||||||
|
// vite: {
|
||||||
|
// server: {
|
||||||
|
// allowedHosts: ["emdash.local"],
|
||||||
|
// },
|
||||||
|
// },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -200,6 +200,67 @@ Use Cloudflare Access as the authentication provider instead of passkeys.
|
|||||||
magic links, and self-signup are disabled.
|
magic links, and self-signup are disabled.
|
||||||
</Aside>
|
</Aside>
|
||||||
|
|
||||||
|
### `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.
|
||||||
|
|
||||||
|
<Aside type="caution">
|
||||||
|
Your reverse proxy should forward a **port-aware** `Host` / `X-Forwarded-Host` when you use non-default ports. If the proxy strips the port, **`rpId`** and Astro’s rebuilt URL can be wrong.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
```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
|
## Database Adapters
|
||||||
|
|
||||||
Import from `emdash/db`:
|
Import from `emdash/db`:
|
||||||
|
|||||||
4
e2e/fixture/emdash-env.d.ts
vendored
4
e2e/fixture/emdash-env.d.ts
vendored
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
/// <reference types="emdash/locals" />
|
/// <reference types="emdash/locals" />
|
||||||
|
|
||||||
import type { PortableTextBlock } from "emdash";
|
import type { ContentBylineCredit, PortableTextBlock } from "emdash";
|
||||||
|
|
||||||
export interface Page {
|
export interface Page {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -14,6 +14,7 @@ export interface Page {
|
|||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
publishedAt: Date | null;
|
publishedAt: Date | null;
|
||||||
|
bylines?: ContentBylineCredit[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Post {
|
export interface Post {
|
||||||
@@ -28,6 +29,7 @@ export interface Post {
|
|||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
publishedAt: Date | null;
|
publishedAt: Date | null;
|
||||||
|
bylines?: ContentBylineCredit[];
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module "emdash" {
|
declare module "emdash" {
|
||||||
|
|||||||
24
e2e/fixtures/refresh-server-pat.ts
Normal file
24
e2e/fixtures/refresh-server-pat.ts
Normal file
@@ -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<void> {
|
||||||
|
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));
|
||||||
|
}
|
||||||
33
e2e/fixtures/virtual-authenticator.ts
Normal file
33
e2e/fixtures/virtual-authenticator.ts
Normal file
@@ -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<void>> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
75
e2e/tests/passkey-full-setup-virtual-auth.spec.ts
Normal file
75
e2e/tests/passkey-full-setup-virtual-auth.spec.ts
Normal file
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect } from "../fixtures";
|
import { test, expect } from "../fixtures";
|
||||||
|
import { refreshServerPatAfterDevBypass } from "../fixtures/refresh-server-pat";
|
||||||
|
|
||||||
const BASE_URL = "http://localhost:4444";
|
const BASE_URL = "http://localhost:4444";
|
||||||
const ADMIN_DASHBOARD_PATTERN = /\/_emdash\/admin\/?$/;
|
const ADMIN_DASHBOARD_PATTERN = /\/_emdash\/admin\/?$/;
|
||||||
@@ -26,10 +27,7 @@ async function resetSetup(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function restoreSetup(): Promise<void> {
|
async function restoreSetup(): Promise<void> {
|
||||||
const res = await fetch(`${BASE_URL}/_emdash/api/setup/dev-bypass?token=1`);
|
await refreshServerPatAfterDevBypass(BASE_URL);
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`dev-bypass failed (${res.status}): ${await res.text()}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test.describe("Setup Wizard", () => {
|
test.describe("Setup Wizard", () => {
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ import { Button, Input } from "@cloudflare/kumo";
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import { apiFetch, parseApiResponse } from "../../lib/api/client";
|
import { apiFetch, parseApiResponse } from "../../lib/api/client";
|
||||||
|
import {
|
||||||
|
isPasskeyEnvironmentUsable,
|
||||||
|
isWebAuthnSecureContext,
|
||||||
|
} from "../../lib/webauthn-environment";
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Constants
|
// Constants
|
||||||
@@ -74,22 +78,11 @@ type LoginState =
|
|||||||
| { status: "error"; message: string }
|
| { status: "error"; message: string }
|
||||||
| { status: "success" };
|
| { 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
|
* Check if conditional mediation (autofill) is supported
|
||||||
*/
|
*/
|
||||||
async function isConditionalMediationSupported(): Promise<boolean> {
|
async function isConditionalMediationSupported(): Promise<boolean> {
|
||||||
if (!isWebAuthnSupported()) return false;
|
if (!isPasskeyEnvironmentUsable()) return false;
|
||||||
try {
|
try {
|
||||||
return (await PublicKeyCredential.isConditionalMediationAvailable?.()) ?? false;
|
return (await PublicKeyCredential.isConditionalMediationAvailable?.()) ?? false;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -142,8 +135,11 @@ export function PasskeyLogin({
|
|||||||
const [email, setEmail] = React.useState("");
|
const [email, setEmail] = React.useState("");
|
||||||
const [supportsConditional, setSupportsConditional] = React.useState(false);
|
const [supportsConditional, setSupportsConditional] = React.useState(false);
|
||||||
|
|
||||||
// Check WebAuthn support on mount
|
const isSupported = React.useMemo(() => isPasskeyEnvironmentUsable(), []);
|
||||||
const isSupported = React.useMemo(() => isWebAuthnSupported(), []);
|
const insecureContext = React.useMemo(
|
||||||
|
() => typeof window !== "undefined" && !isWebAuthnSecureContext(),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
// Check conditional mediation support
|
// Check conditional mediation support
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -155,7 +151,9 @@ export function PasskeyLogin({
|
|||||||
if (!isSupported) {
|
if (!isSupported) {
|
||||||
setState({
|
setState({
|
||||||
status: "error",
|
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;
|
return;
|
||||||
}
|
}
|
||||||
@@ -280,17 +278,37 @@ export function PasskeyLogin({
|
|||||||
onError?.(new Error(userMessage));
|
onError?.(new Error(userMessage));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isSupported, optionsEndpoint, verifyEndpoint, email, supportsConditional, onSuccess, onError],
|
[
|
||||||
|
isSupported,
|
||||||
|
insecureContext,
|
||||||
|
optionsEndpoint,
|
||||||
|
verifyEndpoint,
|
||||||
|
email,
|
||||||
|
supportsConditional,
|
||||||
|
onSuccess,
|
||||||
|
onError,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Not supported message
|
|
||||||
if (!isSupported) {
|
if (!isSupported) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-kumo-danger/50 bg-kumo-danger/10 p-4">
|
<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">
|
<p className="mt-1 text-sm text-kumo-subtle">
|
||||||
Your browser doesn't support passkeys. Please use a modern browser like Chrome, Safari,
|
{insecureContext ? (
|
||||||
Firefox, or Edge.
|
<>
|
||||||
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ import { Button, Input } from "@cloudflare/kumo";
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import { apiFetch, parseApiResponse } from "../../lib/api/client";
|
import { apiFetch, parseApiResponse } from "../../lib/api/client";
|
||||||
|
import {
|
||||||
|
isPasskeyEnvironmentUsable,
|
||||||
|
isWebAuthnSecureContext,
|
||||||
|
} from "../../lib/webauthn-environment";
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Constants
|
// Constants
|
||||||
@@ -95,17 +99,6 @@ type RegistrationState =
|
|||||||
| { status: "error"; message: string }
|
| { status: "error"; message: string }
|
||||||
| { status: "success" };
|
| { 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
|
* Convert base64url to ArrayBuffer
|
||||||
*/
|
*/
|
||||||
@@ -153,8 +146,12 @@ export function PasskeyRegistration({
|
|||||||
});
|
});
|
||||||
const [passkeyName, setPasskeyName] = React.useState("");
|
const [passkeyName, setPasskeyName] = React.useState("");
|
||||||
|
|
||||||
// Check WebAuthn support on mount
|
// Secure context (HTTPS or http://localhost) + PublicKeyCredential
|
||||||
const isSupported = React.useMemo(() => isWebAuthnSupported(), []);
|
const isSupported = React.useMemo(() => isPasskeyEnvironmentUsable(), []);
|
||||||
|
const insecureContext = React.useMemo(
|
||||||
|
() => typeof window !== "undefined" && !isWebAuthnSecureContext(),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const handleRegister = React.useCallback(async () => {
|
const handleRegister = React.useCallback(async () => {
|
||||||
if (!isSupported) {
|
if (!isSupported) {
|
||||||
@@ -292,14 +289,26 @@ export function PasskeyRegistration({
|
|||||||
onError,
|
onError,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Not supported message
|
// Not usable (insecure origin vs missing API — browser hides WebAuthn the same way)
|
||||||
if (!isSupported) {
|
if (!isSupported) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-kumo-danger/50 bg-kumo-danger/10 p-4">
|
<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">
|
<p className="mt-1 text-sm text-kumo-subtle">
|
||||||
Your browser doesn't support passkeys. Please use a modern browser like Chrome, Safari,
|
{insecureContext ? (
|
||||||
Firefox, or Edge.
|
<>
|
||||||
|
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>
|
</p>
|
||||||
</div>
|
</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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
// Plugin descriptors from config
|
||||||
const pluginDescriptors = resolvedConfig.plugins ?? [];
|
const pluginDescriptors = resolvedConfig.plugins ?? [];
|
||||||
const sandboxedDescriptors = resolvedConfig.sandboxed ?? [];
|
const sandboxedDescriptors = resolvedConfig.sandboxed ?? [];
|
||||||
@@ -136,6 +152,7 @@ export function emdash(config: EmDashConfig = {}): AstroIntegration {
|
|||||||
storage: resolvedConfig.storage,
|
storage: resolvedConfig.storage,
|
||||||
auth: resolvedConfig.auth,
|
auth: resolvedConfig.auth,
|
||||||
marketplace: resolvedConfig.marketplace,
|
marketplace: resolvedConfig.marketplace,
|
||||||
|
passkeyPublicOrigin: resolvedConfig.passkeyPublicOrigin,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Determine auth mode for route injection
|
// Determine auth mode for route injection
|
||||||
|
|||||||
@@ -262,6 +262,19 @@ export interface EmDashConfig {
|
|||||||
*/
|
*/
|
||||||
marketplace?: string;
|
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.
|
* Enable playground mode for ephemeral "try EmDash" sites.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { OptionsRepository } from "#db/repositories/options.js";
|
|||||||
|
|
||||||
export const POST: APIRoute = async ({ request, locals, session }) => {
|
export const POST: APIRoute = async ({ request, locals, session }) => {
|
||||||
const { emdash } = locals;
|
const { emdash } = locals;
|
||||||
|
const passkeyPublicOrigin = emdash?.config.passkeyPublicOrigin;
|
||||||
|
|
||||||
if (!emdash?.db) {
|
if (!emdash?.db) {
|
||||||
return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500);
|
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 url = new URL(request.url);
|
||||||
const options = new OptionsRepository(emdash.db);
|
const options = new OptionsRepository(emdash.db);
|
||||||
const siteName = (await options.get<string>("emdash:site_title")) ?? undefined;
|
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
|
// Verify the passkey registration response
|
||||||
const challengeStore = createChallengeStore(emdash.db);
|
const challengeStore = createChallengeStore(emdash.db);
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { OptionsRepository } from "#db/repositories/options.js";
|
|||||||
|
|
||||||
export const POST: APIRoute = async ({ request, locals }) => {
|
export const POST: APIRoute = async ({ request, locals }) => {
|
||||||
const { emdash } = locals;
|
const { emdash } = locals;
|
||||||
|
const passkeyPublicOrigin = emdash?.config.passkeyPublicOrigin;
|
||||||
|
|
||||||
if (!emdash?.db) {
|
if (!emdash?.db) {
|
||||||
return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500);
|
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 url = new URL(request.url);
|
||||||
const options = new OptionsRepository(emdash.db);
|
const options = new OptionsRepository(emdash.db);
|
||||||
const siteName = (await options.get<string>("emdash:site_title")) ?? undefined;
|
const siteName = (await options.get<string>("emdash:site_title")) ?? undefined;
|
||||||
const passkeyConfig = getPasskeyConfig(url, siteName);
|
const passkeyConfig = getPasskeyConfig(url, siteName, passkeyPublicOrigin);
|
||||||
|
|
||||||
// Generate authentication options
|
// Generate authentication options
|
||||||
const challengeStore = createChallengeStore(emdash.db);
|
const challengeStore = createChallengeStore(emdash.db);
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const MAX_PASSKEYS = 10;
|
|||||||
|
|
||||||
export const POST: APIRoute = async ({ request, locals }) => {
|
export const POST: APIRoute = async ({ request, locals }) => {
|
||||||
const { emdash, user } = locals;
|
const { emdash, user } = locals;
|
||||||
|
const passkeyPublicOrigin = emdash?.config.passkeyPublicOrigin;
|
||||||
|
|
||||||
if (!emdash?.db) {
|
if (!emdash?.db) {
|
||||||
return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500);
|
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 url = new URL(request.url);
|
||||||
const optionsRepo = new OptionsRepository(emdash.db);
|
const optionsRepo = new OptionsRepository(emdash.db);
|
||||||
const siteName = (await optionsRepo.get<string>("emdash:site_title")) ?? undefined;
|
const siteName = (await optionsRepo.get<string>("emdash:site_title")) ?? undefined;
|
||||||
const passkeyConfig = getPasskeyConfig(url, siteName);
|
const passkeyConfig = getPasskeyConfig(url, siteName, passkeyPublicOrigin);
|
||||||
|
|
||||||
// Generate registration options
|
// Generate registration options
|
||||||
const challengeStore = createChallengeStore(emdash.db);
|
const challengeStore = createChallengeStore(emdash.db);
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ interface PasskeyResponse {
|
|||||||
|
|
||||||
export const POST: APIRoute = async ({ request, locals }) => {
|
export const POST: APIRoute = async ({ request, locals }) => {
|
||||||
const { emdash, user } = locals;
|
const { emdash, user } = locals;
|
||||||
|
const passkeyPublicOrigin = emdash?.config.passkeyPublicOrigin;
|
||||||
|
|
||||||
if (!emdash?.db) {
|
if (!emdash?.db) {
|
||||||
return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500);
|
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 url = new URL(request.url);
|
||||||
const optionsRepo = new OptionsRepository(emdash.db);
|
const optionsRepo = new OptionsRepository(emdash.db);
|
||||||
const siteName = (await optionsRepo.get<string>("emdash:site_title")) ?? undefined;
|
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
|
// Verify the registration response
|
||||||
const challengeStore = createChallengeStore(emdash.db);
|
const challengeStore = createChallengeStore(emdash.db);
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { OptionsRepository } from "#db/repositories/options.js";
|
|||||||
|
|
||||||
export const POST: APIRoute = async ({ request, locals, session }) => {
|
export const POST: APIRoute = async ({ request, locals, session }) => {
|
||||||
const { emdash } = locals;
|
const { emdash } = locals;
|
||||||
|
const passkeyPublicOrigin = emdash?.config.passkeyPublicOrigin;
|
||||||
|
|
||||||
if (!emdash?.db) {
|
if (!emdash?.db) {
|
||||||
return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500);
|
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 url = new URL(request.url);
|
||||||
const options = new OptionsRepository(emdash.db);
|
const options = new OptionsRepository(emdash.db);
|
||||||
const siteName = (await options.get<string>("emdash:site_title")) ?? undefined;
|
const siteName = (await options.get<string>("emdash:site_title")) ?? undefined;
|
||||||
const passkeyConfig = getPasskeyConfig(url, siteName);
|
const passkeyConfig = getPasskeyConfig(url, siteName, passkeyPublicOrigin);
|
||||||
|
|
||||||
// Authenticate with passkey
|
// Authenticate with passkey
|
||||||
const adapter = createKyselyAdapter(emdash.db);
|
const adapter = createKyselyAdapter(emdash.db);
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { OptionsRepository } from "#db/repositories/options.js";
|
|||||||
|
|
||||||
export const POST: APIRoute = async ({ request, locals, session }) => {
|
export const POST: APIRoute = async ({ request, locals, session }) => {
|
||||||
const { emdash } = locals;
|
const { emdash } = locals;
|
||||||
|
const passkeyPublicOrigin = emdash?.config.passkeyPublicOrigin;
|
||||||
|
|
||||||
if (!emdash?.db) {
|
if (!emdash?.db) {
|
||||||
return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500);
|
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 url = new URL(request.url);
|
||||||
const options = new OptionsRepository(emdash.db);
|
const options = new OptionsRepository(emdash.db);
|
||||||
const siteName = (await options.get<string>("emdash:site_title")) ?? undefined;
|
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
|
// Verify the passkey registration response
|
||||||
const challengeStore = createChallengeStore(emdash.db);
|
const challengeStore = createChallengeStore(emdash.db);
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { OptionsRepository } from "#db/repositories/options.js";
|
|||||||
|
|
||||||
export const POST: APIRoute = async ({ request, locals }) => {
|
export const POST: APIRoute = async ({ request, locals }) => {
|
||||||
const { emdash } = locals;
|
const { emdash } = locals;
|
||||||
|
const passkeyPublicOrigin = emdash?.config.passkeyPublicOrigin;
|
||||||
|
|
||||||
if (!emdash?.db) {
|
if (!emdash?.db) {
|
||||||
return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500);
|
return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500);
|
||||||
@@ -57,7 +58,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
// Get passkey config
|
// Get passkey config
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const siteName = (await options.get<string>("emdash:site_title")) ?? undefined;
|
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
|
// Verify the registration response
|
||||||
const challengeStore = createChallengeStore(emdash.db);
|
const challengeStore = createChallengeStore(emdash.db);
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { OptionsRepository } from "#db/repositories/options.js";
|
|||||||
|
|
||||||
export const POST: APIRoute = async ({ request, locals }) => {
|
export const POST: APIRoute = async ({ request, locals }) => {
|
||||||
const { emdash } = locals;
|
const { emdash } = locals;
|
||||||
|
const passkeyPublicOrigin = emdash?.config.passkeyPublicOrigin;
|
||||||
|
|
||||||
if (!emdash?.db) {
|
if (!emdash?.db) {
|
||||||
return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500);
|
return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500);
|
||||||
@@ -56,7 +57,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
// Get passkey config
|
// Get passkey config
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const siteName = (await options.get<string>("emdash:site_title")) ?? undefined;
|
const siteName = (await options.get<string>("emdash:site_title")) ?? undefined;
|
||||||
const passkeyConfig = getPasskeyConfig(url, siteName);
|
const passkeyConfig = getPasskeyConfig(url, siteName, passkeyPublicOrigin);
|
||||||
|
|
||||||
// Generate registration options
|
// Generate registration options
|
||||||
const challengeStore = createChallengeStore(emdash.db);
|
const challengeStore = createChallengeStore(emdash.db);
|
||||||
|
|||||||
@@ -15,10 +15,31 @@ export interface PasskeyConfig {
|
|||||||
/**
|
/**
|
||||||
* Get passkey configuration from request URL
|
* Get passkey configuration from request URL
|
||||||
*
|
*
|
||||||
* @param url The request URL
|
* @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)
|
* @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 {
|
return {
|
||||||
rpName: siteName || url.hostname,
|
rpName: siteName || url.hostname,
|
||||||
rpId: url.hostname,
|
rpId: url.hostname,
|
||||||
|
|||||||
@@ -2,8 +2,54 @@ import { describe, it, expect } from "vitest";
|
|||||||
|
|
||||||
import { getPasskeyConfig } from "../../../src/auth/passkey-config.js";
|
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("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()", () => {
|
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", () => {
|
it("extracts rpId from localhost URL", () => {
|
||||||
const url = new URL("http://localhost:4321/admin");
|
const url = new URL("http://localhost:4321/admin");
|
||||||
const config = getPasskeyConfig(url);
|
const config = getPasskeyConfig(url);
|
||||||
@@ -78,5 +124,47 @@ describe("passkey-config", () => {
|
|||||||
expect(config.origin).toBe("https://example.com");
|
expect(config.origin).toBe("https://example.com");
|
||||||
expect(config.rpId).toBe("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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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)
|
### Cloudflare (D1 + R2)
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
|
|||||||
Reference in New Issue
Block a user