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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user