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
5Self-hosted instances now detect their public URL automatically on common
6platforms. When `EXECUTOR_WEB_BASE_URL` is not set, the server reads the origin
7a host injects (Railway, Render, Fly, Vercel, Netlify, Heroku, Azure, and
8Cloudflare Pages) instead of defaulting to localhost — so a platform deploy
9works with zero configuration and no longer fails sign-in with "Invalid origin".
10When the origin still can't be determined, that error is replaced with a clear
11message 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
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 carry91 // 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). With102 // `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
2 2
3import { IdentityProvider } from "@executor-js/api/server";3import { IdentityProvider } from "@executor-js/api/server";
4 4
5import { loadConfig } from "../config";
5import type { SelfHostDbHandle } from "../db/self-host-db";6import type { SelfHostDbHandle } from "../db/self-host-db";
6import { BetterAuth, buildBetterAuth, type BetterAuthHandle } from "./better-auth";7import { BetterAuth, buildBetterAuth, type BetterAuthHandle } from "./better-auth";
7import { betterAuthIdentityLayer } from "./identity";8import { betterAuthIdentityLayer } from "./identity";
8import { consentRedirectClientId, withClientName, withForcedMcpConsent } from "./force-mcp-consent";9import { consentRedirectClientId, withClientName, withForcedMcpConsent } from "./force-mcp-consent";
10import { rewriteInvalidOrigin } from "./invalid-origin-help";
9 11
10export { BetterAuth, buildBetterAuth, type BetterAuthHandle } from "./better-auth";12export { BetterAuth, buildBetterAuth, type BetterAuthHandle } from "./better-auth";
11export { betterAuthIdentityLayer } from "./identity";13export { 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 than58 // authorize so a connecting client is gated on /mcp-consent rather than
57 // silently granted a token (see ./force-mcp-consent), and enrich the59 // 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;
apps/host-selfhost/src/auth/invalid-origin-help.test.ts+50−0
@@ -0,0 +1,50 @@
1import { expect, test } from "@effect/vitest";
2
3import { invalidOriginHelp, originOf, rewriteInvalidOrigin } from "./invalid-origin-help";
4
5const req = (headers: Record<string, string>) =>
6 new Request("https://svc.internal/api/auth/sign-up/email", { method: "POST", headers });
7
8test("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
16test("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
23test("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
44test("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
8const INVALID_ORIGIN = /invalid origin/i;
9
10/** The origin a request came from: the browser `Origin`, else the proxy host. */
11export 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". */
21export 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 */
34export 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 @@
1import { mkdtempSync } from "node:fs";
2import { tmpdir } from "node:os";
3import { join } from "node:path";
4
5import { expect, test } from "@effect/vitest";
6
7import { loadConfig } from "../config";
8
9// Keep generated secret/key files out of the repo (loadConfig persists them).
10process.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).
21const 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];
29const resetOriginEnv = (): void => {
30 for (const key of PLATFORM_VARS) delete process.env[key];
31 process.env.PORT = "4788";
32};
33
34test("webBaseUrl falls back to localhost with no public origin", () => {
35 resetOriginEnv();
36 expect(loadConfig().webBaseUrl).toBe("http://localhost:4788");
37});
38
39test("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
45test("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
51test("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
57test("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
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.
113const 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
133let 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).
142const 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
106export const loadConfig = (): SelfHostConfig => {161export 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,