From 32110ea39d96c6f072fc58b9932ca85d4d1f5674 Mon Sep 17 00:00:00 2001 From: Jeremy Hamilton Date: Mon, 18 May 2026 13:28:38 -0700 Subject: [PATCH] feat: secure docker self-host auth --- README.md | 12 ++- compose.yaml | 4 +- docs/SELF_HOSTING_DOCKER.md | 89 +++++++++++++++---- src/lib/self-host-auth-config.test.ts | 69 ++++++++++++++ src/lib/self-host-auth-config.ts | 45 ++++++++++ .../ensure-user/cloudflareAccess.ts | 7 ++ src/server.ts | 10 +++ src/server/mcp/transport.test.ts | 23 +++++ src/server/mcp/transport.ts | 2 +- 9 files changed, 240 insertions(+), 21 deletions(-) create mode 100644 src/lib/self-host-auth-config.test.ts create mode 100644 src/lib/self-host-auth-config.ts diff --git a/README.md b/README.md index 80ce1c6..3e7c1dc 100644 --- a/README.md +++ b/README.md @@ -116,12 +116,18 @@ Prerequisites: - Install Docker: https://www.docker.com/products/docker-desktop/ -Quickstart: +Secure quickstart: 1. `cp .env.example .env` 2. Set `DATAFORSEO_API_KEY` in `.env` -3. `docker compose up -d` -4. Open `http://localhost:` (default `3001`) +3. Keep the default `AUTH_MODE=cloudflare_access` and set `TEAM_DOMAIN` + `POLICY_AUD` from your Cloudflare Access application +4. Set `ALLOWED_HOST` to your protected hostname when deploying through Coolify or a reverse proxy +5. `docker compose up -d` +6. Open the Cloudflare Access-protected hostname, or `http://localhost:` (default `3001`) for local testing + +For trusted local-only development without Cloudflare Access, explicitly set +`AUTH_MODE=local_noauth`. Do not expose `local_noauth` deployments directly to +the internet. By default, `compose.yaml` pulls the published image from GHCR: diff --git a/compose.yaml b/compose.yaml index 42b718e..ffc47ae 100644 --- a/compose.yaml +++ b/compose.yaml @@ -7,7 +7,9 @@ services: - CLOUDFLARE_INCLUDE_PROCESS_ENV=true - PORT=${PORT:-3001} - ALLOWED_HOST=${ALLOWED_HOST:-} - - AUTH_MODE=local_noauth + - AUTH_MODE=${AUTH_MODE:-cloudflare_access} + - TEAM_DOMAIN=${TEAM_DOMAIN:-} + - POLICY_AUD=${POLICY_AUD:-} - DATAFORSEO_API_KEY=${DATAFORSEO_API_KEY} - VITE_SHOW_DEVTOOLS=false ports: diff --git a/docs/SELF_HOSTING_DOCKER.md b/docs/SELF_HOSTING_DOCKER.md index 66ef274..eeb0567 100644 --- a/docs/SELF_HOSTING_DOCKER.md +++ b/docs/SELF_HOSTING_DOCKER.md @@ -1,8 +1,15 @@ # Docker Self-Hosting -Run OpenSEO locally with Docker. +Run OpenSEO locally or in Coolify with Docker. -In Docker mode, OpenSEO uses `AUTH_MODE=local_noauth` (no auth checks, local admin user `admin@localhost`). Only expose it behind your own auth-protected reverse proxy, tunnel, or private network. +Docker self-hosting is secure-by-default: `compose.yaml` defaults to +`AUTH_MODE=cloudflare_access` and passes Cloudflare Access settings into the +container so the app and MCP endpoint validate the `cf-access-jwt-assertion` +header before creating an OpenSEO user context. + +Use `AUTH_MODE=local_noauth` only for trusted local development on a private +machine or private network. Do not expose `local_noauth` deployments directly to +the internet. The default `compose.yaml` uses the published GHCR image: @@ -11,32 +18,65 @@ The default `compose.yaml` uses the published GHCR image: ## Prerequisites - Docker Desktop (or Docker Engine + Docker Compose) +- A DataForSEO API credential encoded as base64 `email:password` +- For production/Coolify: a Cloudflare Access application in front of the OpenSEO route + +## Secure quickstart: Docker or Coolify behind Cloudflare Access -## Quickstart +1. Copy the example environment file: ```bash cp .env.example .env +``` + +2. Set the required values in `.env`: + +```bash +DATAFORSEO_API_KEY=base64-email-colon-password +AUTH_MODE=cloudflare_access +TEAM_DOMAIN=https://your-team.cloudflareaccess.com +POLICY_AUD=your-cloudflare-access-aud-tag +ALLOWED_HOST=seo.example.com +``` + +3. Start the container: + +```bash docker compose up -d ``` -Set `DATAFORSEO_API_KEY` in `.env`, then open `http://localhost:` (default `3001`). +Docker Compose passes `.env` values into the container. `compose.yaml` also sets +`CLOUDFLARE_INCLUDE_PROCESS_ENV=true` so the Cloudflare Vite runtime can read +those values as Worker bindings during Docker self-hosting. + +If `AUTH_MODE=cloudflare_access` is selected but `TEAM_DOMAIN` or `POLICY_AUD` +is missing, OpenSEO returns a clear configuration error instead of silently +falling back to unauthenticated mode. + +## Coolify notes -Docker Compose passes `.env` values into the container, and `compose.yaml` enables `CLOUDFLARE_INCLUDE_PROCESS_ENV=true` so the Cloudflare Vite runtime can read them as Worker bindings during local self-hosting. +For Coolify, configure these as service environment variables: -Optional env values: +- `DATAFORSEO_API_KEY`: base64 `email:password` +- `AUTH_MODE=cloudflare_access` +- `TEAM_DOMAIN=https://your-team.cloudflareaccess.com` +- `POLICY_AUD`: Cloudflare Access application AUD tag +- `ALLOWED_HOST`: the public hostname Coolify/Cloudflare routes to OpenSEO +- `PORT=3001` unless you intentionally change the exposed app port -- `PORT` (defaults to `3001`) -- `ALLOWED_HOST` (single reverse-proxy hostname to allow in Vite preview) -- `AUTH_MODE=local_noauth` (already set in compose) -- `OPEN_SEO_IMAGE` (defaults to `ghcr.io/every-app/open-seo:latest`) +Keep Cloudflare Access enabled on the public route. OpenSEO validates Access JWTs +for both the browser app and the self-hosted MCP transport. -If you are putting Docker behind a reverse proxy or a temporary tunnel, remember that Docker self-hosting runs with app auth disabled. Only expose it behind your own auth-protected reverse proxy, tunnel, or private network, and add the public hostname before restarting: +## Trusted local development mode + +For local-only testing without Cloudflare Access, explicitly opt in: ```bash -ALLOWED_HOST=yourdomain.com docker compose up -d +AUTH_MODE=local_noauth docker compose up -d ``` -You can also persist it in `.env`. +`local_noauth` injects the local admin user `admin@localhost`. It is intended for +trusted local development only. ## Pin to a specific image tag @@ -90,12 +130,29 @@ To confirm Docker Compose is using the expected environment variables: docker compose config ``` -Check that `AUTH_MODE=local_noauth`, and that `DATAFORSEO_API_KEY` is the base64 -encoded value of your DataForSEO email and API password in this format: -`email:password`. +Check that the rendered config includes: + +- `AUTH_MODE=cloudflare_access` for secure deployments, or an explicit + `AUTH_MODE=local_noauth` only for trusted local development +- `TEAM_DOMAIN=https://your-team.cloudflareaccess.com` +- `POLICY_AUD=your-cloudflare-access-aud-tag` +- `CLOUDFLARE_INCLUDE_PROCESS_ENV=true` +- `DATAFORSEO_API_KEY` as the base64 encoded value of your DataForSEO email and + API password in this format: `email:password` If you changed `.env`, recreate the container so Compose reapplies it: ```bash docker compose up -d --force-recreate open-seo ``` + +## Manual verification path + +1. Start with `AUTH_MODE=cloudflare_access`, `TEAM_DOMAIN`, and `POLICY_AUD` set. +2. Access the app through the Cloudflare-protected hostname and confirm the UI + loads after Access authentication. +3. Call the MCP route through the same protected hostname and confirm requests + without a valid `cf-access-jwt-assertion` are rejected. +4. Temporarily remove `POLICY_AUD` and restart; the app should return a clear + `AUTH_MODE=cloudflare_access requires TEAM_DOMAIN and POLICY_AUD` error. +5. Restore `POLICY_AUD` and recreate the container. diff --git a/src/lib/self-host-auth-config.test.ts b/src/lib/self-host-auth-config.test.ts new file mode 100644 index 0000000..9656058 --- /dev/null +++ b/src/lib/self-host-auth-config.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { + assertSelfHostedAuthConfig, + getSelfHostedAuthConfigMessage, +} from "./self-host-auth-config"; + +describe("self-hosted auth configuration", () => { + it("accepts Cloudflare Access when TEAM_DOMAIN and POLICY_AUD are present", () => { + expect(() => + assertSelfHostedAuthConfig({ + AUTH_MODE: "cloudflare_access", + TEAM_DOMAIN: "https://team.cloudflareaccess.com", + POLICY_AUD: "access-aud-tag", + }), + ).not.toThrow(); + }); + + it("rejects Cloudflare Access with a clear missing-variable message", () => { + expect(() => + assertSelfHostedAuthConfig({ + AUTH_MODE: "cloudflare_access", + TEAM_DOMAIN: "", + POLICY_AUD: undefined, + }), + ).toThrow( + "AUTH_MODE=cloudflare_access requires TEAM_DOMAIN and POLICY_AUD", + ); + }); + + it("keeps local_noauth explicitly available for trusted local development", () => { + expect(() => + assertSelfHostedAuthConfig({ AUTH_MODE: "local_noauth" }), + ).not.toThrow(); + }); + + it("returns null when self-hosted auth config is complete", () => { + expect( + getSelfHostedAuthConfigMessage({ + AUTH_MODE: "cloudflare_access", + TEAM_DOMAIN: "https://team.cloudflareaccess.com", + POLICY_AUD: "access-aud-tag", + }), + ).toBeNull(); + }); +}); + +describe("Docker self-hosting files", () => { + it("defaults compose deployments to Cloudflare Access and passes required env through", () => { + const compose = readFileSync(resolve("compose.yaml"), "utf8"); + + expect(compose).toContain("AUTH_MODE=${AUTH_MODE:-cloudflare_access}"); + expect(compose).toContain("TEAM_DOMAIN=${TEAM_DOMAIN:-}"); + expect(compose).toContain("POLICY_AUD=${POLICY_AUD:-}"); + expect(compose).toContain("CLOUDFLARE_INCLUDE_PROCESS_ENV=true"); + expect(compose).not.toContain("AUTH_MODE=local_noauth"); + }); + + it("documents Cloudflare Access as the first-class Docker auth path", () => { + const docs = readFileSync(resolve("docs/SELF_HOSTING_DOCKER.md"), "utf8"); + + expect(docs).toContain("AUTH_MODE=cloudflare_access"); + expect(docs).toContain("TEAM_DOMAIN"); + expect(docs).toContain("POLICY_AUD"); + expect(docs).toContain("local_noauth"); + expect(docs).toContain("trusted local development"); + }); +}); diff --git a/src/lib/self-host-auth-config.ts b/src/lib/self-host-auth-config.ts new file mode 100644 index 0000000..c78a09d --- /dev/null +++ b/src/lib/self-host-auth-config.ts @@ -0,0 +1,45 @@ +import { getAuthMode } from "./auth-mode"; + +type SelfHostedAuthEnv = { + AUTH_MODE?: string | null; + TEAM_DOMAIN?: string | null; + POLICY_AUD?: string | null; +}; + +const CLOUDFLARE_ACCESS_MISSING_MESSAGE = + "AUTH_MODE=cloudflare_access requires TEAM_DOMAIN and POLICY_AUD"; + +function hasValue(value: string | null | undefined) { + return typeof value === "string" && value.trim().length > 0; +} + +export function getSelfHostedAuthConfigMessage( + env: SelfHostedAuthEnv, +): string | null { + const authMode = getAuthMode(env.AUTH_MODE); + + if (authMode !== "cloudflare_access") { + return null; + } + + const missing = [ + hasValue(env.TEAM_DOMAIN) ? null : "TEAM_DOMAIN", + hasValue(env.POLICY_AUD) ? null : "POLICY_AUD", + ].filter(Boolean); + + if (missing.length === 0) { + return null; + } + + return `${CLOUDFLARE_ACCESS_MISSING_MESSAGE}. Missing: ${missing.join( + ", ", + )}. Set AUTH_MODE=local_noauth only for trusted local development.`; +} + +export function assertSelfHostedAuthConfig(env: SelfHostedAuthEnv) { + const message = getSelfHostedAuthConfigMessage(env); + + if (message) { + throw new Error(message); + } +} diff --git a/src/middleware/ensure-user/cloudflareAccess.ts b/src/middleware/ensure-user/cloudflareAccess.ts index 54b7394..9c82e32 100644 --- a/src/middleware/ensure-user/cloudflareAccess.ts +++ b/src/middleware/ensure-user/cloudflareAccess.ts @@ -1,5 +1,6 @@ import { env } from "cloudflare:workers"; import { createRemoteJWKSet, jwtVerify } from "jose"; +import { getSelfHostedAuthConfigMessage } from "@/lib/self-host-auth-config"; import { AppError } from "@/server/lib/errors"; import { resolveDelegatedContext } from "./delegated"; import type { EnsuredUserContext } from "./types"; @@ -46,6 +47,12 @@ function getValidatedTeamDomain(teamDomain: string) { export async function resolveCloudflareAccessContext( headers: Headers, ): Promise { + const configMessage = getSelfHostedAuthConfigMessage(env); + + if (configMessage) { + throw new AppError("AUTH_CONFIG_MISSING", configMessage); + } + const teamDomain = env.TEAM_DOMAIN ? getValidatedTeamDomain(env.TEAM_DOMAIN) : null; diff --git a/src/server.ts b/src/server.ts index 621b23a..bf9b28e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -15,6 +15,7 @@ import { requestWithPublicOrigin } from "@/server/mcp/public-origin"; import { MCP_ROUTE } from "@/server/mcp/context"; import { handleSelfHostedOpenSeoMcpRequest } from "@/server/mcp/transport"; import { computeNextCheckAt } from "@/shared/rank-tracking"; +import { getSelfHostedAuthConfigMessage } from "@/lib/self-host-auth-config"; const appFetch = createStartHandler(defaultStreamHandler); const handleAppFetch = (request: Request): Response | Promise => @@ -29,6 +30,15 @@ function fetch( const authMode = getAuthMode(env.AUTH_MODE); const publicRequest = requestWithPublicOrigin(request); + const selfHostedAuthConfigMessage = getSelfHostedAuthConfigMessage(env); + + if (selfHostedAuthConfigMessage) { + return new Response(selfHostedAuthConfigMessage, { + status: 500, + headers: { "Content-Type": "text/plain; charset=utf-8" }, + }); + } + if (isHostedAuthMode(authMode)) { return openSeoOAuthProvider.fetch( publicRequest, diff --git a/src/server/mcp/transport.test.ts b/src/server/mcp/transport.test.ts index 4266b5f..609031a 100644 --- a/src/server/mcp/transport.test.ts +++ b/src/server/mcp/transport.test.ts @@ -62,6 +62,12 @@ const transportOptionsSchema = z.object({ props: z.record(z.string(), z.unknown()), }) .optional(), + corsOptions: z + .object({ + headers: z.string().optional(), + exposeHeaders: z.string().optional(), + }) + .optional(), }), }); @@ -155,6 +161,23 @@ describe("handleSelfHostedOpenSeoMcpRequest", () => { }); }); + it("allows the Cloudflare Access assertion header on self-hosted MCP CORS", async () => { + const { handleSelfHostedOpenSeoMcpRequest } = + await import("@/server/mcp/transport"); + + const response = await handleSelfHostedOpenSeoMcpRequest( + createMcpRequest(), + "cloudflare_access", + {}, + ctx, + ); + const body = transportOptionsSchema.parse(await response.json()); + + expect(body.options.corsOptions?.headers).toContain( + "cf-access-jwt-assertion", + ); + }); + it("lets the MCP transport handle OPTIONS without auth context", async () => { const { handleSelfHostedOpenSeoMcpRequest } = await import("@/server/mcp/transport"); diff --git a/src/server/mcp/transport.ts b/src/server/mcp/transport.ts index ba86826..f2759a6 100644 --- a/src/server/mcp/transport.ts +++ b/src/server/mcp/transport.ts @@ -87,7 +87,7 @@ function handleOpenSeoMcpRequest( authContext: props ? { props } : undefined, corsOptions: { headers: - "Authorization, Content-Type, Last-Event-ID, mcp-protocol-version, mcp-session-id", + "Authorization, Content-Type, Last-Event-ID, cf-access-jwt-assertion, mcp-protocol-version, mcp-session-id", exposeHeaders: "mcp-protocol-version, mcp-session-id", }, });