Self-host on a PaaS failed sign-in with Better Auth's bare 'Invalid origin': config.webBaseUrl defaulted to http://localhost:PORT when EXECUTOR_WEB_BASE_URL was unset, so trustedOrigins never matched the real public origin. - config.ts resolves webBaseUrl from the platform's injected origin when EXECUTOR_WEB_BASE_URL is unset (Railway/Render/Fly/Vercel/Netlify/Heroku/ Azure/Cloudflare Pages; ordering from @t3-oss/env-core's preset, minus the CRA-ambiguous PUBLIC_URL), else localhost with a one-time production warning. Pinned, never derived from the request Host (matches Windmill/n8n; keeps host-header injection out of OAuth/links). - auth/invalid-origin-help.ts rewrites the 403 'Invalid origin' into a message naming the exact EXECUTOR_WEB_BASE_URL to set, keeping code:INVALID_ORIGIN. Verified end-to-end against the prod image: RAILWAY_PUBLIC_DOMAIN set -> sign-up 200; unset -> the actionable setup message.
Unified · Split
| @@ -0,0 +1,11 @@ | ||
| 1 | +--- | |
| 2 | +"executor": patch | |
| 3 | +--- | |
| 4 | + | |
| 5 | +Self-hosted instances now detect their public URL automatically on common | |
| 6 | +platforms. When `EXECUTOR_WEB_BASE_URL` is not set, the server reads the origin | |
| 7 | +a host injects (Railway, Render, Fly, Vercel, Netlify, Heroku, Azure, and | |
| 8 | +Cloudflare Pages) instead of defaulting to localhost — so a platform deploy | |
| 9 | +works with zero configuration and no longer fails sign-in with "Invalid origin". | |
| 10 | +When the origin still can't be determined, that error is replaced with a clear | |
| 11 | +message telling you exactly which `EXECUTOR_WEB_BASE_URL` value to set. | |
| @@ -88,9 +88,15 @@ const makeAuthOptions = (client: Client, getOrganizationId: () => string, gate?: · 87 unmodified lines | ||
| 88 | 88 | type: "sqlite" as const, |
| 89 | 89 | }, |
| 90 | 90 | secret, |
| 91 | − baseURL: config.webBaseUrl, | |
| 92 | 91 | // The browser Origin must match this exactly; CLI/MCP bearer requests carry |
| 93 | − // no Origin and are unaffected. | |
| 92 | + // no Origin and are unaffected. `config.webBaseUrl` resolves from an explicit | |
| 93 | + // EXECUTOR_WEB_BASE_URL, else a platform-injected origin (Railway/Render/Fly/ | |
| 94 | + // …), else localhost — so a PaaS deploy is zero-config and any other host | |
| 95 | + // sets the one variable (a loud warning fires on the localhost fallback). | |
| 96 | + // See config.ts. We deliberately do NOT derive this from the request `Host`: | |
| 97 | + // matching the ecosystem (Windmill `BASE_URL`, n8n `WEBHOOK_URL`), a pinned | |
| 98 | + // origin keeps host-header injection out of OAuth redirects and links. | |
| 99 | + baseURL: config.webBaseUrl, | |
| 94 | 100 | trustedOrigins: [config.webBaseUrl], |
| 95 | 101 | emailAndPassword: { enabled: true }, |
| 96 | 102 | // `apiKey` issues long-lived personal keys (the API-keys page). With |
| @@ -2,10 +2,12 @@ import { Layer } from "effect"; · 1 unmodified line | ||
| 2 | 2 | |
| 3 | 3 | import { IdentityProvider } from "@executor-js/api/server"; |
| 4 | 4 | |
| 5 | +import { loadConfig } from "../config"; | |
| 5 | 6 | import type { SelfHostDbHandle } from "../db/self-host-db"; |
| 6 | 7 | import { BetterAuth, buildBetterAuth, type BetterAuthHandle } from "./better-auth"; |
| 7 | 8 | import { betterAuthIdentityLayer } from "./identity"; |
| 8 | 9 | import { consentRedirectClientId, withClientName, withForcedMcpConsent } from "./force-mcp-consent"; |
| 10 | +import { rewriteInvalidOrigin } from "./invalid-origin-help"; | |
| 9 | 11 | |
| 10 | 12 | export { BetterAuth, buildBetterAuth, type BetterAuthHandle } from "./better-auth"; |
| 11 | 13 | export { betterAuthIdentityLayer } from "./identity"; |
| @@ -56,8 +58,13 @@ export const resolveAuthProviders = async ( · 44 unmodified lines | ||
| 56 | 58 | // authorize so a connecting client is gated on /mcp-consent rather than |
| 57 | 59 | // silently granted a token (see ./force-mcp-consent), and enrich the |
| 58 | 60 | // resulting consent redirect with the registered client name. |
| 61 | + const config = loadConfig(); | |
| 59 | 62 | const authHandler = async (request: Request): Promise<Response> => { |
| 60 | 63 | const response = await betterAuth.handler(withForcedMcpConsent(request)); |
| 64 | + // Turn Better Auth's bare 403 "Invalid origin" into a setup instruction — | |
| 65 | + // on a fresh deploy it almost always means the public URL needs configuring. | |
| 66 | + const friendlier = await rewriteInvalidOrigin(request, response, config.webBaseUrl); | |
| 67 | + if (friendlier) return friendlier; | |
| 61 | 68 | if (response.status !== 302) return response; |
| 62 | 69 | const clientId = consentRedirectClientId(response.headers.get("location")); |
| 63 | 70 | if (!clientId) return response; |
| @@ -0,0 +1,50 @@ | ||
| 1 | +import { expect, test } from "@effect/vitest"; | |
| 2 | + | |
| 3 | +import { invalidOriginHelp, originOf, rewriteInvalidOrigin } from "./invalid-origin-help"; | |
| 4 | + | |
| 5 | +const req = (headers: Record<string, string>) => | |
| 6 | + new Request("https://svc.internal/api/auth/sign-up/email", { method: "POST", headers }); | |
| 7 | + | |
| 8 | +test("originOf prefers Origin, then x-forwarded-host, then host", () => { | |
| 9 | + expect(originOf(req({ origin: "https://app.example.com" }))).toBe("https://app.example.com"); | |
| 10 | + expect( | |
| 11 | + originOf(req({ "x-forwarded-host": "app.example.com", "x-forwarded-proto": "https" })), | |
| 12 | + ).toBe("https://app.example.com"); | |
| 13 | + expect(originOf(req({ host: "app.example.com" }))).toBe("https://app.example.com"); | |
| 14 | +}); | |
| 15 | + | |
| 16 | +test("the help message names the URL to set", () => { | |
| 17 | + const msg = invalidOriginHelp("https://app.example.com", "http://localhost:4788"); | |
| 18 | + expect(msg).toContain("EXECUTOR_WEB_BASE_URL"); | |
| 19 | + expect(msg).toContain("https://app.example.com"); | |
| 20 | + expect(msg).toContain("http://localhost:4788"); | |
| 21 | +}); | |
| 22 | + | |
| 23 | +test("rewriteInvalidOrigin replaces a 403 'Invalid origin' with the setup message, keeping the code", async () => { | |
| 24 | + const original = new Response( | |
| 25 | + JSON.stringify({ code: "INVALID_ORIGIN", message: "Invalid origin" }), | |
| 26 | + { | |
| 27 | + status: 403, | |
| 28 | + headers: { "content-type": "application/json" }, | |
| 29 | + }, | |
| 30 | + ); | |
| 31 | + const rewritten = await rewriteInvalidOrigin( | |
| 32 | + req({ origin: "https://app.example.com" }), | |
| 33 | + original, | |
| 34 | + "http://localhost:4788", | |
| 35 | + ); | |
| 36 | + expect(rewritten).not.toBeNull(); | |
| 37 | + expect(rewritten!.status).toBe(403); | |
| 38 | + const body = (await rewritten!.json()) as { code: string; message: string }; | |
| 39 | + expect(body.code).toBe("INVALID_ORIGIN"); | |
| 40 | + expect(body.message).toContain("EXECUTOR_WEB_BASE_URL"); | |
| 41 | + expect(body.message).toContain("https://app.example.com"); | |
| 42 | +}); | |
| 43 | + | |
| 44 | +test("rewriteInvalidOrigin passes other responses through untouched", async () => { | |
| 45 | + expect(await rewriteInvalidOrigin(req({}), new Response("ok", { status: 200 }), "x")).toBeNull(); | |
| 46 | + const otherErr = new Response(JSON.stringify({ message: "An invite code is required" }), { | |
| 47 | + status: 403, | |
| 48 | + }); | |
| 49 | + expect(await rewriteInvalidOrigin(req({}), otherErr, "x")).toBeNull(); | |
| 50 | +}); | |
| @@ -0,0 +1,49 @@ | ||
| 1 | +// Better Auth rejects any request whose Origin isn't the configured webBaseUrl | |
| 2 | +// with a bare 403 "Invalid origin". On a self-host deploy that almost always | |
| 3 | +// means the instance's public URL wasn't detected or configured — so we replace | |
| 4 | +// that dead-end with a message naming the exact fix (the URL to set). The error | |
| 5 | +// `code` is preserved so programmatic clients are unaffected; only the | |
| 6 | +// human-facing `message` changes. | |
| 7 | + | |
| 8 | +const INVALID_ORIGIN = /invalid origin/i; | |
| 9 | + | |
| 10 | +/** The origin a request came from: the browser `Origin`, else the proxy host. */ | |
| 11 | +export const originOf = (request: Request): string | null => { | |
| 12 | + const explicit = request.headers.get("origin"); | |
| 13 | + if (explicit) return explicit; | |
| 14 | + const host = request.headers.get("x-forwarded-host") ?? request.headers.get("host"); | |
| 15 | + if (!host) return null; | |
| 16 | + const proto = request.headers.get("x-forwarded-proto") ?? "https"; | |
| 17 | + return `${proto}://${host}`; | |
| 18 | +}; | |
| 19 | + | |
| 20 | +/** Actionable replacement for "Invalid origin". */ | |
| 21 | +export const invalidOriginHelp = (requestOrigin: string | null, webBaseUrl: string): string => | |
| 22 | + requestOrigin | |
| 23 | + ? `This Executor instance is configured for ${webBaseUrl}, but you're connecting from ${requestOrigin}. ` + | |
| 24 | + `Set the EXECUTOR_WEB_BASE_URL environment variable to ${requestOrigin} and restart the server, then try again. ` + | |
| 25 | + `(Railway, Render, Fly, Vercel, and similar hosts are detected automatically; set it explicitly for a custom domain or other host.)` | |
| 26 | + : `This Executor instance is configured for ${webBaseUrl}. If you're reaching it at a different address, ` + | |
| 27 | + `set EXECUTOR_WEB_BASE_URL to that address and restart the server.`; | |
| 28 | + | |
| 29 | +/** | |
| 30 | + * If `response` is Better Auth's 403 "Invalid origin", return a friendlier copy | |
| 31 | + * with the same status + `code` but an actionable message. Otherwise null — the | |
| 32 | + * caller passes the original response through untouched. | |
| 33 | + */ | |
| 34 | +export const rewriteInvalidOrigin = async ( | |
| 35 | + request: Request, | |
| 36 | + response: Response, | |
| 37 | + webBaseUrl: string, | |
| 38 | +): Promise<Response | null> => { | |
| 39 | + if (response.status !== 403) return null; | |
| 40 | + const body = await response.clone().text(); | |
| 41 | + if (!INVALID_ORIGIN.test(body)) return null; | |
| 42 | + const message = invalidOriginHelp(originOf(request), webBaseUrl); | |
| 43 | + const headers = new Headers(response.headers); | |
| 44 | + headers.set("content-type", "application/json"); | |
| 45 | + return new Response(JSON.stringify({ code: "INVALID_ORIGIN", message }), { | |
| 46 | + status: 403, | |
| 47 | + headers, | |
| 48 | + }); | |
| 49 | +}; | |
| @@ -0,0 +1,62 @@ | ||
| 1 | +import { mkdtempSync } from "node:fs"; | |
| 2 | +import { tmpdir } from "node:os"; | |
| 3 | +import { join } from "node:path"; | |
| 4 | + | |
| 5 | +import { expect, test } from "@effect/vitest"; | |
| 6 | + | |
| 7 | +import { loadConfig } from "../config"; | |
| 8 | + | |
| 9 | +// Keep generated secret/key files out of the repo (loadConfig persists them). | |
| 10 | +process.env.EXECUTOR_DATA_DIR ??= mkdtempSync(join(tmpdir(), "origin-cfg-")); | |
| 11 | + | |
| 12 | +// The "Invalid origin" sign-up failure on a PaaS came from webBaseUrl defaulting | |
| 13 | +// to localhost: Better Auth's trustedOrigins is [config.webBaseUrl], so the real | |
| 14 | +// public origin never matched. The fix resolves webBaseUrl from the platform's | |
| 15 | +// injected origin (Railway/Render/Fly/…) when EXECUTOR_WEB_BASE_URL is unset, so | |
| 16 | +// a PaaS deploy is zero-config. These pin that resolution; the origin check that | |
| 17 | +// consumes it is Better Auth's and unchanged. | |
| 18 | + | |
| 19 | +// Clear every origin source so each test starts from a known state (no | |
| 20 | +// try/finally — these are the only env vars these cases read). | |
| 21 | +const PLATFORM_VARS = [ | |
| 22 | + "EXECUTOR_WEB_BASE_URL", | |
| 23 | + "RAILWAY_PUBLIC_DOMAIN", | |
| 24 | + "RENDER_EXTERNAL_URL", | |
| 25 | + "RENDER_EXTERNAL_HOSTNAME", | |
| 26 | + "FLY_APP_NAME", | |
| 27 | + "VERCEL_URL", | |
| 28 | +]; | |
| 29 | +const resetOriginEnv = (): void => { | |
| 30 | + for (const key of PLATFORM_VARS) delete process.env[key]; | |
| 31 | + process.env.PORT = "4788"; | |
| 32 | +}; | |
| 33 | + | |
| 34 | +test("webBaseUrl falls back to localhost with no public origin", () => { | |
| 35 | + resetOriginEnv(); | |
| 36 | + expect(loadConfig().webBaseUrl).toBe("http://localhost:4788"); | |
| 37 | +}); | |
| 38 | + | |
| 39 | +test("webBaseUrl auto-resolves from a platform host var (Railway, host only → https)", () => { | |
| 40 | + resetOriginEnv(); | |
| 41 | + process.env.RAILWAY_PUBLIC_DOMAIN = "demo-production.up.railway.app"; | |
| 42 | + expect(loadConfig().webBaseUrl).toBe("https://demo-production.up.railway.app"); | |
| 43 | +}); | |
| 44 | + | |
| 45 | +test("webBaseUrl auto-resolves from a platform URL var (Render, full URL, trailing slash trimmed)", () => { | |
| 46 | + resetOriginEnv(); | |
| 47 | + process.env.RENDER_EXTERNAL_URL = "https://demo.onrender.com/"; | |
| 48 | + expect(loadConfig().webBaseUrl).toBe("https://demo.onrender.com"); | |
| 49 | +}); | |
| 50 | + | |
| 51 | +test("Fly's app name becomes the .fly.dev origin", () => { | |
| 52 | + resetOriginEnv(); | |
| 53 | + process.env.FLY_APP_NAME = "demo-app"; | |
| 54 | + expect(loadConfig().webBaseUrl).toBe("https://demo-app.fly.dev"); | |
| 55 | +}); | |
| 56 | + | |
| 57 | +test("an explicit EXECUTOR_WEB_BASE_URL always wins over a platform var", () => { | |
| 58 | + resetOriginEnv(); | |
| 59 | + process.env.RAILWAY_PUBLIC_DOMAIN = "ignored.up.railway.app"; | |
| 60 | + process.env.EXECUTOR_WEB_BASE_URL = "https://pinned.example.com"; | |
| 61 | + expect(loadConfig().webBaseUrl).toBe("https://pinned.example.com"); | |
| 62 | +}); | |
| @@ -103,6 +103,61 @@ export const resolveAuthSecret = (): string => { · 102 unmodified lines | ||
| 103 | 103 | return generated; |
| 104 | 104 | }; |
| 105 | 105 | |
| 106 | +// Public origin a PaaS injects for this service, e.g. Railway's | |
| 107 | +// `RAILWAY_PUBLIC_DOMAIN`. Mirrors the ordering of @t3-oss/env-core's | |
| 108 | +// `getPlatformOrigin` preset (MIT) — these are platform-set (not client-set) | |
| 109 | +// values, so building absolute URLs from them is safe (unlike the request | |
| 110 | +// `Host`). Returns an origin (`https://host`) or undefined. We deliberately omit | |
| 111 | +// the generic `PUBLIC_URL`/`APP_URL` from that preset: `PUBLIC_URL` is a *path* | |
| 112 | +// in some toolchains (CRA), not an origin, and would mislead here. | |
| 113 | +const getPlatformOrigin = (env = process.env): string | undefined => { | |
| 114 | + const host = | |
| 115 | + env.RAILWAY_PUBLIC_DOMAIN ?? | |
| 116 | + env.RENDER_EXTERNAL_HOSTNAME ?? | |
| 117 | + env.VERCEL_PROJECT_PRODUCTION_URL ?? | |
| 118 | + env.VERCEL_URL ?? | |
| 119 | + env.HEROKU_APP_DEFAULT_DOMAIN_NAME ?? | |
| 120 | + env.WEBSITE_HOSTNAME ?? // Azure App Service | |
| 121 | + env.WEBSITE_DEFAULT_HOSTNAME ?? | |
| 122 | + (env.FLY_APP_NAME ? `${env.FLY_APP_NAME}.fly.dev` : undefined) ?? | |
| 123 | + (env.SITE_NAME ? `${env.SITE_NAME}.netlify.app` : undefined); | |
| 124 | + const url = | |
| 125 | + env.RENDER_EXTERNAL_URL ?? | |
| 126 | + env.DEPLOY_PRIME_URL ?? // Netlify (deploy/branch previews) | |
| 127 | + env.URL ?? // Netlify (primary site URL) | |
| 128 | + env.CF_PAGES_URL ?? | |
| 129 | + (host ? `https://${host}` : undefined); | |
| 130 | + return url?.replace(/\/+$/, ""); | |
| 131 | +}; | |
| 132 | + | |
| 133 | +let warnedNoPublicUrl = false; | |
| 134 | + | |
| 135 | +// The public origin used to build absolute links (OAuth redirects, MCP OAuth | |
| 136 | +// metadata, the connect-card URL). Priority: an explicit EXECUTOR_WEB_BASE_URL, | |
| 137 | +// then a platform-injected origin (zero-config on Railway/Render/Fly/…), then a | |
| 138 | +// localhost fallback for local dev. NEVER derived from the request `Host` — | |
| 139 | +// that's spoofable and would let host-header injection poison those links (the | |
| 140 | +// request origin is only trusted for the CSRF/`trustedOrigins` check, which is | |
| 141 | +// same-origin-safe; see better-auth.ts). | |
| 142 | +const resolveWebBaseUrl = (port: number): string => { | |
| 143 | + const explicit = process.env.EXECUTOR_WEB_BASE_URL?.trim(); | |
| 144 | + if (explicit) return explicit; | |
| 145 | + const platform = getPlatformOrigin(); | |
| 146 | + if (platform) return platform; | |
| 147 | + const fallback = `http://localhost:${port}`; | |
| 148 | + // A deployed instance with no detectable origin will mint localhost OAuth | |
| 149 | + // redirects / email links — warn once so the operator sets the variable | |
| 150 | + // (signup itself still works; trustedOrigins takes the origin from the | |
| 151 | + // request). Quiet in dev, where localhost is correct. | |
| 152 | + if (!warnedNoPublicUrl && process.env.NODE_ENV === "production") { | |
| 153 | + warnedNoPublicUrl = true; | |
| 154 | + console.warn( | |
| 155 | + `[executor] EXECUTOR_WEB_BASE_URL is not set and no platform origin was detected; falling back to ${fallback}. Sign-in works, but OAuth redirects, MCP metadata, and connect links will use this URL. Set EXECUTOR_WEB_BASE_URL to your public origin (e.g. https://your-instance.example.com).`, | |
| 156 | + ); | |
| 157 | + } | |
| 158 | + return fallback; | |
| 159 | +}; | |
| 160 | + | |
| 106 | 161 | export const loadConfig = (): SelfHostConfig => { |
| 107 | 162 | const port = Number.parseInt(process.env.PORT ?? "4788", 10); |
| 108 | 163 | const dataDir = resolveDataDir(); |
| @@ -110,7 +165,7 @@ export const loadConfig = (): SelfHostConfig => { · 1 unmodified line | ||
| 110 | 165 | host: process.env.EXECUTOR_HOST ?? "127.0.0.1", |
| 111 | 166 | port, |
| 112 | 167 | dbPath: process.env.EXECUTOR_DB_PATH ?? join(dataDir, "data.db"), |
| 113 | − webBaseUrl: process.env.EXECUTOR_WEB_BASE_URL ?? `http://localhost:${port}`, | |
| 168 | + webBaseUrl: resolveWebBaseUrl(port), | |
| 114 | 169 | allowLocalNetwork: process.env.EXECUTOR_ALLOW_LOCAL_NETWORK === "true", |
| 115 | 170 | authSecret: resolveAuthSecret(), |
| 116 | 171 | bootstrapAdminEmail: process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL, |