diff --git a/.github/workflows/deploy-den.yml b/.github/workflows/deploy-den.yml index 548b98848..bea69d219 100644 --- a/.github/workflows/deploy-den.yml +++ b/.github/workflows/deploy-den.yml @@ -86,6 +86,7 @@ jobs: DEN_BETTER_AUTH_SECRET: ${{ secrets.DEN_BETTER_AUTH_SECRET }} DEN_GITHUB_CLIENT_ID: ${{ secrets.DEN_GITHUB_CLIENT_ID }} DEN_GITHUB_CLIENT_SECRET: ${{ secrets.DEN_GITHUB_CLIENT_SECRET }} + DEN_BETTER_AUTH_URL: ${{ vars.DEN_BETTER_AUTH_URL }} DEN_RENDER_WORKER_PLAN: ${{ vars.DEN_RENDER_WORKER_PLAN }} DEN_RENDER_WORKER_OPENWORK_VERSION: ${{ vars.DEN_RENDER_WORKER_OPENWORK_VERSION }} DEN_CORS_ORIGINS: ${{ vars.DEN_CORS_ORIGINS }} @@ -134,6 +135,7 @@ jobs: polar_benefit_id = os.environ.get("POLAR_BENEFIT_ID") or "" github_client_id = os.environ.get("DEN_GITHUB_CLIENT_ID") or "" github_client_secret = os.environ.get("DEN_GITHUB_CLIENT_SECRET") or "" + better_auth_url = os.environ.get("DEN_BETTER_AUTH_URL") or "https://app.openwork.software" if bool(github_client_id) != bool(github_client_secret): raise RuntimeError( @@ -147,6 +149,7 @@ jobs: validate_redirect_url("DEN_POLAR_SUCCESS_URL", polar_success_url) validate_redirect_url("DEN_POLAR_RETURN_URL", polar_return_url) + validate_redirect_url("DEN_BETTER_AUTH_URL", better_auth_url) if paywall_enabled and (not polar_access_token or not polar_product_id or not polar_benefit_id): raise RuntimeError( @@ -216,7 +219,7 @@ jobs: env_vars = [ {"key": "DATABASE_URL", "value": os.environ["DEN_DATABASE_URL"]}, {"key": "BETTER_AUTH_SECRET", "value": os.environ["DEN_BETTER_AUTH_SECRET"]}, - {"key": "BETTER_AUTH_URL", "value": service_url}, + {"key": "BETTER_AUTH_URL", "value": better_auth_url}, {"key": "GITHUB_CLIENT_ID", "value": github_client_id}, {"key": "GITHUB_CLIENT_SECRET", "value": github_client_secret}, {"key": "CORS_ORIGINS", "value": cors_origins}, diff --git a/packages/web/README.md b/packages/web/README.md index db49b609f..4b794281c 100644 --- a/packages/web/README.md +++ b/packages/web/README.md @@ -8,6 +8,7 @@ Frontend for `app.openwork.software`. - Launches cloud workers via `POST /v1/workers`. - Handles paywall responses (`402 payment_required`) and shows Polar checkout links. - Uses a Next.js proxy route (`/api/den/*`) to reach `api.openwork.software` without browser CORS issues. +- Uses a same-origin auth proxy (`/api/auth/*`) so GitHub OAuth callbacks can land on `app.openwork.software`. ## Local development @@ -22,13 +23,16 @@ Frontend for `app.openwork.software`. - `DEN_API_BASE` (server-only): upstream API base used by proxy route. - default: `https://api.openwork.software` -- `DEN_AUTH_ORIGIN` (server-only): Origin header sent to Better Auth endpoints. +- `DEN_AUTH_ORIGIN` (server-only): Origin header sent to Better Auth endpoints when the browser request does not include one. + - default: `https://app.openwork.software` +- `DEN_AUTH_FALLBACK_BASE` (server-only): fallback Den origin used if `DEN_API_BASE` serves an HTML/5xx error. - default: `https://den-control-plane-openwork.onrender.com` - `NEXT_PUBLIC_OPENWORK_APP_CONNECT_URL` (client): Base URL for "Open in App" links. - Example: `https://openwork.software/app` - The web panel appends `/connect-remote` and injects worker URL/token params automatically. - `NEXT_PUBLIC_OPENWORK_AUTH_CALLBACK_URL` (client): Canonical URL used for GitHub auth callback redirects. - default: `https://app.openwork.software` + - this host must serve `/api/auth/*`; the included proxy route does that - `NEXT_PUBLIC_POSTHOG_KEY` (client): PostHog project key used for Den analytics. - set this to the same project key used by `packages/landing` - `NEXT_PUBLIC_POSTHOG_HOST` (client): PostHog host URL. diff --git a/packages/web/app/api/_lib/upstream-proxy.ts b/packages/web/app/api/_lib/upstream-proxy.ts new file mode 100644 index 000000000..8005f68de --- /dev/null +++ b/packages/web/app/api/_lib/upstream-proxy.ts @@ -0,0 +1,265 @@ +import { NextRequest } from "next/server"; + +const DEFAULT_API_BASE = "https://api.openwork.software"; +const DEFAULT_AUTH_ORIGIN = "https://app.openwork.software"; +const DEFAULT_AUTH_FALLBACK_BASE = "https://den-control-plane-openwork.onrender.com"; +const NO_BODY_STATUS = new Set([204, 205, 304]); + +const apiBase = normalizeBaseUrl(process.env.DEN_API_BASE ?? DEFAULT_API_BASE); +const authOrigin = normalizeBaseUrl(process.env.DEN_AUTH_ORIGIN ?? DEFAULT_AUTH_ORIGIN); +const authFallbackBase = normalizeBaseUrl(process.env.DEN_AUTH_FALLBACK_BASE ?? DEFAULT_AUTH_FALLBACK_BASE); + +type ProxyOptions = { + routePrefix: string; + upstreamPathPrefix?: string; + rewriteAuthLocationsToRequestOrigin?: boolean; +}; + +function normalizeBaseUrl(value: string): string { + return value.trim().replace(/\/+$/, ""); +} + +function normalizePathPrefix(value: string): string { + return value.replace(/^\/+|\/+$/g, ""); +} + +function getTargetPath(request: NextRequest, segments: string[], routePrefix: string): string { + const incoming = new URL(request.url); + let targetPath = segments.join("/"); + + if (!targetPath) { + const normalizedPrefix = routePrefix.endsWith("/") ? routePrefix : `${routePrefix}/`; + if (incoming.pathname.startsWith(normalizedPrefix)) { + targetPath = incoming.pathname.slice(normalizedPrefix.length); + } else if (incoming.pathname === routePrefix) { + targetPath = ""; + } + } + + return targetPath; +} + +function buildTargetUrl( + base: string, + request: NextRequest, + targetPath: string, + upstreamPathPrefix = "", +): string { + const incoming = new URL(request.url); + const prefixedPath = [normalizePathPrefix(upstreamPathPrefix), targetPath].filter(Boolean).join("/"); + const upstream = new URL(prefixedPath ? `${base}/${prefixedPath}` : base); + upstream.search = incoming.search; + return upstream.toString(); +} + +function isLikelyHtmlBody(body: ArrayBuffer): boolean { + if (body.byteLength === 0) { + return false; + } + + const preview = new TextDecoder().decode(body.slice(0, 256)).trim().toLowerCase(); + return preview.startsWith(" string[] }).getSetCookie; + if (typeof getSetCookie === "function") { + const cookies = getSetCookie.call(upstreamHeaders); + for (const cookie of cookies) { + if (cookie) { + responseHeaders.append("set-cookie", cookie); + } + } + return; + } + + const cookie = upstreamHeaders.get("set-cookie"); + if (cookie) { + responseHeaders.append("set-cookie", cookie); + } +} + +function buildHeaders(request: NextRequest, contentType: string | null): Headers { + const headers = new Headers(); + const copyHeaders = [ + "accept", + "authorization", + "cookie", + "user-agent", + "x-requested-with", + "origin", + "x-forwarded-for", + ]; + + for (const key of copyHeaders) { + const value = request.headers.get(key); + if (value) { + headers.set(key, value); + } + } + + if (contentType) { + headers.set("content-type", contentType); + } + + if (!headers.has("accept")) { + headers.set("accept", "application/json"); + } + + if (!headers.has("origin")) { + headers.set("origin", authOrigin); + } + + const incoming = new URL(request.url); + headers.set("x-forwarded-host", request.headers.get("host") ?? incoming.host); + headers.set("x-forwarded-proto", incoming.protocol.replace(/:$/, "")); + + return headers; +} + +async function fetchUpstream( + request: NextRequest, + targetUrl: string, + contentType: string | null, + body: Uint8Array | null, +): Promise<{ response: Response; body: ArrayBuffer }> { + const init: RequestInit = { + method: request.method, + headers: buildHeaders(request, contentType), + redirect: "manual", + }; + + if (body && request.method !== "GET" && request.method !== "HEAD") { + init.body = body; + } + + const response = await fetch(targetUrl, init); + const responseBody = await response.arrayBuffer(); + return { response, body: responseBody }; +} + +function rewriteLocationHeader(location: string, request: NextRequest): string { + let parsedLocation: URL; + try { + parsedLocation = new URL(location); + } catch { + return location; + } + + const requestOrigin = new URL(request.url).origin; + const rewriteableOrigins = [apiBase, authFallbackBase] + .map((value) => { + try { + return new URL(value).origin; + } catch { + return null; + } + }) + .filter((value): value is string => Boolean(value)); + + if (!rewriteableOrigins.includes(parsedLocation.origin) || !parsedLocation.pathname.startsWith("/api/auth/")) { + return location; + } + + return `${requestOrigin}${parsedLocation.pathname}${parsedLocation.search}${parsedLocation.hash}`; +} + +export async function proxyUpstream( + request: NextRequest, + segments: string[] = [], + options: ProxyOptions, +): Promise { + const targetPath = getTargetPath(request, segments, options.routePrefix); + const primaryTargetUrl = buildTargetUrl(apiBase, request, targetPath, options.upstreamPathPrefix); + const fallbackTargetUrl = buildTargetUrl(authFallbackBase, request, targetPath, options.upstreamPathPrefix); + const contentType = request.headers.get("content-type"); + const requestBody = request.method !== "GET" && request.method !== "HEAD" + ? new Uint8Array(await request.arrayBuffer()) + : null; + + let upstream: Response | null = null; + let body: ArrayBuffer | null = null; + + try { + const primary = await fetchUpstream(request, primaryTargetUrl, contentType, requestBody); + upstream = primary.response; + body = primary.body; + } catch { + if (apiBase !== authFallbackBase) { + try { + const fallback = await fetchUpstream(request, fallbackTargetUrl, contentType, requestBody); + upstream = fallback.response; + body = fallback.body; + } catch {} + } + } + + if (!upstream || !body) { + return buildUpstreamErrorResponse(502, "Upstream request failed."); + } + + if (apiBase !== authFallbackBase && shouldFallbackToAuthBase(upstream, body)) { + try { + const fallback = await fetchUpstream(request, fallbackTargetUrl, contentType, requestBody); + upstream = fallback.response; + body = fallback.body; + } catch {} + } + + const responseContentType = upstream.headers.get("content-type")?.toLowerCase() ?? ""; + if (upstream.status >= 500 && (responseContentType.includes("text/html") || isLikelyHtmlBody(body))) { + return buildUpstreamErrorResponse(upstream.status, "Upstream service unavailable."); + } + + const responseHeaders = new Headers(); + const passThroughHeaders = ["content-type", "location", "cache-control"]; + + for (const key of passThroughHeaders) { + const value = upstream.headers.get(key); + if (!value) { + continue; + } + + if (key === "location" && options.rewriteAuthLocationsToRequestOrigin) { + responseHeaders.set(key, rewriteLocationHeader(value, request)); + continue; + } + + responseHeaders.set(key, value); + } + + copySetCookieHeaders(upstream.headers, responseHeaders); + + const shouldDropBody = request.method === "HEAD" || NO_BODY_STATUS.has(upstream.status); + + return new Response(shouldDropBody ? null : body, { + status: upstream.status, + headers: responseHeaders, + }); +} diff --git a/packages/web/app/api/auth/[...path]/route.ts b/packages/web/app/api/auth/[...path]/route.ts new file mode 100644 index 000000000..e1c2fec34 --- /dev/null +++ b/packages/web/app/api/auth/[...path]/route.ts @@ -0,0 +1,32 @@ +import { NextRequest } from "next/server"; +import { proxyUpstream } from "../../_lib/upstream-proxy"; + +export const dynamic = "force-dynamic"; + +async function proxy(request: NextRequest, segments: string[] = []) { + return proxyUpstream(request, segments, { + routePrefix: "/api/auth", + upstreamPathPrefix: "api/auth", + rewriteAuthLocationsToRequestOrigin: true, + }); +} + +export async function GET(request: NextRequest) { + return proxy(request); +} + +export async function POST(request: NextRequest) { + return proxy(request); +} + +export async function PUT(request: NextRequest) { + return proxy(request); +} + +export async function PATCH(request: NextRequest) { + return proxy(request); +} + +export async function DELETE(request: NextRequest) { + return proxy(request); +} diff --git a/packages/web/app/api/den/[...path]/route.ts b/packages/web/app/api/den/[...path]/route.ts index aba5d81be..510d4f4c3 100644 --- a/packages/web/app/api/den/[...path]/route.ts +++ b/packages/web/app/api/den/[...path]/route.ts @@ -1,195 +1,11 @@ import { NextRequest } from "next/server"; - -const DEFAULT_API_BASE = "https://api.openwork.software"; -const DEFAULT_AUTH_ORIGIN = "https://den-control-plane-openwork.onrender.com"; -const apiBase = (process.env.DEN_API_BASE ?? DEFAULT_API_BASE).replace(/\/+$/, ""); -const authOrigin = (process.env.DEN_AUTH_ORIGIN ?? DEFAULT_AUTH_ORIGIN).replace(/\/+$/, ""); +import { proxyUpstream } from "../../_lib/upstream-proxy"; export const dynamic = "force-dynamic"; -const NO_BODY_STATUS = new Set([204, 205, 304]); - -function getTargetPath(request: NextRequest, segments: string[]): string { - const incoming = new URL(request.url); - let targetPath = segments.join("/"); - - if (!targetPath) { - const prefix = "/api/den/"; - if (incoming.pathname.startsWith(prefix)) { - targetPath = incoming.pathname.slice(prefix.length); - } else if (incoming.pathname === "/api/den") { - targetPath = ""; - } - } - - return targetPath; -} - -function buildTargetUrl(base: string, request: NextRequest, targetPath: string): string { - const incoming = new URL(request.url); - const upstream = new URL(`${base}/${targetPath}`); - upstream.search = incoming.search; - return upstream.toString(); -} - -function isLikelyHtmlBody(body: ArrayBuffer): boolean { - if (body.byteLength === 0) { - return false; - } - - const preview = new TextDecoder().decode(body.slice(0, 256)).trim().toLowerCase(); - return preview.startsWith(" string[] }).getSetCookie; - if (typeof getSetCookie === "function") { - const cookies = getSetCookie.call(upstreamHeaders); - for (const cookie of cookies) { - if (cookie) { - responseHeaders.append("set-cookie", cookie); - } - } - return; - } - - const cookie = upstreamHeaders.get("set-cookie"); - if (cookie) { - responseHeaders.append("set-cookie", cookie); - } -} - -function buildHeaders(request: NextRequest, contentType: string | null): Headers { - const headers = new Headers(); - const copyHeaders = ["accept", "authorization", "cookie", "user-agent", "x-requested-with", "origin"]; - - for (const key of copyHeaders) { - const value = request.headers.get(key); - if (value) { - headers.set(key, value); - } - } - - if (contentType) { - headers.set("content-type", contentType); - } - - if (!headers.has("accept")) { - headers.set("accept", "application/json"); - } - - if (!headers.has("origin")) { - headers.set("origin", authOrigin); - } - - return headers; -} - -async function fetchUpstream( - request: NextRequest, - targetUrl: string, - contentType: string | null, - body: Uint8Array | null, -): Promise<{ response: Response; body: ArrayBuffer }> { - const init: RequestInit = { - method: request.method, - headers: buildHeaders(request, contentType), - redirect: "manual", - }; - - if (body && request.method !== "GET" && request.method !== "HEAD") { - init.body = body; - } - - const response = await fetch(targetUrl, init); - const responseBody = await response.arrayBuffer(); - return { response, body: responseBody }; -} - async function proxy(request: NextRequest, segments: string[] = []) { - const targetPath = getTargetPath(request, segments); - const primaryTargetUrl = buildTargetUrl(apiBase, request, targetPath); - const fallbackTargetUrl = buildTargetUrl(authOrigin, request, targetPath); - const contentType = request.headers.get("content-type"); - const requestBody = request.method !== "GET" && request.method !== "HEAD" ? new Uint8Array(await request.arrayBuffer()) : null; - - let upstream: Response | null = null; - let body: ArrayBuffer | null = null; - - try { - const primary = await fetchUpstream(request, primaryTargetUrl, contentType, requestBody); - upstream = primary.response; - body = primary.body; - } catch { - if (apiBase !== authOrigin) { - try { - const fallback = await fetchUpstream(request, fallbackTargetUrl, contentType, requestBody); - upstream = fallback.response; - body = fallback.body; - } catch {} - } - } - - if (!upstream || !body) { - return buildUpstreamErrorResponse(502, "Upstream request failed."); - } - - if (apiBase !== authOrigin && shouldFallbackToAuthOrigin(upstream, body)) { - try { - const fallback = await fetchUpstream(request, fallbackTargetUrl, contentType, requestBody); - upstream = fallback.response; - body = fallback.body; - } catch {} - } - - const responseContentType = upstream.headers.get("content-type")?.toLowerCase() ?? ""; - if (upstream.status >= 500 && (responseContentType.includes("text/html") || isLikelyHtmlBody(body))) { - return buildUpstreamErrorResponse(upstream.status, "Upstream service unavailable."); - } - - const responseHeaders = new Headers(); - const passThroughHeaders = ["content-type", "location", "cache-control"]; - - for (const key of passThroughHeaders) { - const value = upstream.headers.get(key); - if (value) { - responseHeaders.set(key, value); - } - } - - copySetCookieHeaders(upstream.headers, responseHeaders); - - const shouldDropBody = request.method === "HEAD" || NO_BODY_STATUS.has(upstream.status); - - return new Response(shouldDropBody ? null : body, { - status: upstream.status, - headers: responseHeaders + return proxyUpstream(request, segments, { + routePrefix: "/api/den", }); } diff --git a/packages/web/components/cloud-control.tsx b/packages/web/components/cloud-control.tsx index ace1a204d..ffcc4e039 100644 --- a/packages/web/components/cloud-control.tsx +++ b/packages/web/components/cloud-control.tsx @@ -839,7 +839,8 @@ async function requestJson(path: string, init: RequestInit = {}, timeoutMs = 300 let response: Response; try { - response = await fetch(`/api/den${path}`, { + const endpoint = path.startsWith("/api/") ? path : `/api/den${path}`; + response = await fetch(endpoint, { ...init, headers, credentials: "include", diff --git a/services/den/README.md b/services/den/README.md index c07571c30..471feab30 100644 --- a/services/den/README.md +++ b/services/den/README.md @@ -14,7 +14,7 @@ pnpm dev - `DATABASE_URL` MySQL connection URL - `BETTER_AUTH_SECRET` 32+ char secret -- `BETTER_AUTH_URL` base URL for auth callbacks +- `BETTER_AUTH_URL` public base URL Better Auth uses for OAuth redirects and callbacks - `GITHUB_CLIENT_ID` optional OAuth app client ID for GitHub sign-in - `GITHUB_CLIENT_SECRET` optional OAuth app client secret for GitHub sign-in - `PORT` server port @@ -104,6 +104,7 @@ Optional GitHub Actions variable: - `DEN_CORS_ORIGINS` (defaults to `https://app.openwork.software,https://api.openwork.software,`) - `DEN_RENDER_WORKER_PUBLIC_DOMAIN_SUFFIX` (defaults to `openwork.studio`) - `DEN_RENDER_CUSTOM_DOMAIN_READY_TIMEOUT_MS` (defaults to `240000`) +- `DEN_BETTER_AUTH_URL` (defaults to `https://app.openwork.software`) - `DEN_VERCEL_API_BASE` (defaults to `https://api.vercel.com`) - `DEN_VERCEL_TEAM_ID` (optional) - `DEN_VERCEL_TEAM_SLUG` (optional, defaults to `prologe`)