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 | type: "sqlite" as const, | 88 | type: "sqlite" as const, |
| 89 | }, | 89 | }, |
| 90 | secret, | 90 | secret, |
| 91 | baseURL: config.webBaseUrl, | ||
| 92 | // The browser Origin must match this exactly; CLI/MCP bearer requests carry | 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 | trustedOrigins: [config.webBaseUrl], | 100 | trustedOrigins: [config.webBaseUrl], |
| 95 | emailAndPassword: { enabled: true }, | 101 | emailAndPassword: { enabled: true }, |
| 96 | // `apiKey` issues long-lived personal keys (the API-keys page). With | 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 | import { IdentityProvider } from "@executor-js/api/server"; | 3 | import { IdentityProvider } from "@executor-js/api/server"; |
| 4 | 4 | ||
| 5 | import { loadConfig } from "../config"; | ||
| 5 | import type { SelfHostDbHandle } from "../db/self-host-db"; | 6 | import type { SelfHostDbHandle } from "../db/self-host-db"; |
| 6 | import { BetterAuth, buildBetterAuth, type BetterAuthHandle } from "./better-auth"; | 7 | import { BetterAuth, buildBetterAuth, type BetterAuthHandle } from "./better-auth"; |
| 7 | import { betterAuthIdentityLayer } from "./identity"; | 8 | import { betterAuthIdentityLayer } from "./identity"; |
| 8 | import { consentRedirectClientId, withClientName, withForcedMcpConsent } from "./force-mcp-consent"; | 9 | import { consentRedirectClientId, withClientName, withForcedMcpConsent } from "./force-mcp-consent"; |
| 10 | import { rewriteInvalidOrigin } from "./invalid-origin-help"; | ||
| 9 | 11 | ||
| 10 | export { BetterAuth, buildBetterAuth, type BetterAuthHandle } from "./better-auth"; | 12 | export { BetterAuth, buildBetterAuth, type BetterAuthHandle } from "./better-auth"; |
| 11 | export { betterAuthIdentityLayer } from "./identity"; | 13 | export { betterAuthIdentityLayer } from "./identity"; |
| @@ -56,8 +58,13 @@ export const resolveAuthProviders = async ( · 44 unmodified lines | |||
| 56 | // authorize so a connecting client is gated on /mcp-consent rather than | 58 | // authorize so a connecting client is gated on /mcp-consent rather than |
| 57 | // silently granted a token (see ./force-mcp-consent), and enrich the | 59 | // silently granted a token (see ./force-mcp-consent), and enrich the |
| 58 | // resulting consent redirect with the registered client name. | 60 | // resulting consent redirect with the registered client name. |
| 61 | const config = loadConfig(); | ||
| 59 | const authHandler = async (request: Request): Promise<Response> => { | 62 | const authHandler = async (request: Request): Promise<Response> => { |
| 60 | const response = await betterAuth.handler(withForcedMcpConsent(request)); | 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 | if (response.status !== 302) return response; | 68 | if (response.status !== 302) return response; |
| 62 | const clientId = consentRedirectClientId(response.headers.get("location")); | 69 | const clientId = consentRedirectClientId(response.headers.get("location")); |
| 63 | if (!clientId) return response; | 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 | return generated; | 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 | export const loadConfig = (): SelfHostConfig => { | 161 | export const loadConfig = (): SelfHostConfig => { |
| 107 | const port = Number.parseInt(process.env.PORT ?? "4788", 10); | 162 | const port = Number.parseInt(process.env.PORT ?? "4788", 10); |
| 108 | const dataDir = resolveDataDir(); | 163 | const dataDir = resolveDataDir(); |
| @@ -110,7 +165,7 @@ export const loadConfig = (): SelfHostConfig => { · 1 unmodified line | |||
| 110 | host: process.env.EXECUTOR_HOST ?? "127.0.0.1", | 165 | host: process.env.EXECUTOR_HOST ?? "127.0.0.1", |
| 111 | port, | 166 | port, |
| 112 | dbPath: process.env.EXECUTOR_DB_PATH ?? join(dataDir, "data.db"), | 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 | allowLocalNetwork: process.env.EXECUTOR_ALLOW_LOCAL_NETWORK === "true", | 169 | allowLocalNetwork: process.env.EXECUTOR_ALLOW_LOCAL_NETWORK === "true", |
| 115 | authSecret: resolveAuthSecret(), | 170 | authSecret: resolveAuthSecret(), |
| 116 | bootstrapAdminEmail: process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL, | 171 | bootstrapAdminEmail: process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL, |