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

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