History

Self-host: auto-detect public URL from platform env + actionable origin error

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.

Rhys Sullivan committed 5 months ago

Commit 86176da1 parent 1e1567e7 files · +243 −3Browse files

Unified · Split

7 files
  1. .changeset/selfhost-zero-config-origin.md +11 −0
  2. apps/host-selfhost/src/auth/better-auth.ts +8 −2
  3. apps/host-selfhost/src/auth/index.ts +7 −0
  4. apps/host-selfhost/src/auth/invalid-origin-help.test.ts +50 −0
  5. apps/host-selfhost/src/auth/invalid-origin-help.ts +49 −0
  6. apps/host-selfhost/src/auth/origin-resolution.test.ts +62 −0
  7. apps/host-selfhost/src/config.ts +56 −1
.changeset/selfhost-zero-config-origin.md+11−0
@@ -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.
apps/host-selfhost/src/auth/better-auth.ts+8−2
@@ -88,9 +88,15 @@ const makeAuthOptions = (client: Client, getOrganizationId: () => string, gate?: · 87 unmodified lines
8888 type: "sqlite" as const,
8989 },
9090 secret,
91− baseURL: config.webBaseUrl,
9291 // 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,
94100 trustedOrigins: [config.webBaseUrl],
95101 emailAndPassword: { enabled: true },
96102 // `apiKey` issues long-lived personal keys (the API-keys page). With
apps/host-selfhost/src/auth/index.ts+7−0
@@ -2,10 +2,12 @@ import { Layer } from "effect"; · 1 unmodified line
22
33 import { IdentityProvider } from "@executor-js/api/server";
44
5+import { loadConfig } from "../config";
56 import type { SelfHostDbHandle } from "../db/self-host-db";
67 import { BetterAuth, buildBetterAuth, type BetterAuthHandle } from "./better-auth";
78 import { betterAuthIdentityLayer } from "./identity";
89 import { consentRedirectClientId, withClientName, withForcedMcpConsent } from "./force-mcp-consent";
10+import { rewriteInvalidOrigin } from "./invalid-origin-help";
911
1012 export { BetterAuth, buildBetterAuth, type BetterAuthHandle } from "./better-auth";
1113 export { betterAuthIdentityLayer } from "./identity";
@@ -56,8 +58,13 @@ export const resolveAuthProviders = async ( · 44 unmodified lines
5658 // authorize so a connecting client is gated on /mcp-consent rather than
5759 // silently granted a token (see ./force-mcp-consent), and enrich the
5860 // resulting consent redirect with the registered client name.
61+ const config = loadConfig();
5962 const authHandler = async (request: Request): Promise<Response> => {
6063 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;
6168 if (response.status !== 302) return response;
6269 const clientId = consentRedirectClientId(response.headers.get("location"));
6370 if (!clientId) return response;
apps/host-selfhost/src/auth/invalid-origin-help.test.ts+50−0
@@ -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+});
apps/host-selfhost/src/auth/invalid-origin-help.ts+49−0
@@ -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+};
apps/host-selfhost/src/auth/origin-resolution.test.ts+62−0
@@ -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+});
apps/host-selfhost/src/config.ts+56−1
@@ -103,6 +103,61 @@ export const resolveAuthSecret = (): string => { · 102 unmodified lines
103103 return generated;
104104 };
105105
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+
106161 export const loadConfig = (): SelfHostConfig => {
107162 const port = Number.parseInt(process.env.PORT ?? "4788", 10);
108163 const dataDir = resolveDataDir();
@@ -110,7 +165,7 @@ export const loadConfig = (): SelfHostConfig => { · 1 unmodified line
110165 host: process.env.EXECUTOR_HOST ?? "127.0.0.1",
111166 port,
112167 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),
114169 allowLocalNetwork: process.env.EXECUTOR_ALLOW_LOCAL_NETWORK === "true",
115170 authSecret: resolveAuthSecret(),
116171 bootstrapAdminEmail: process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL,