diff --git a/server/src/__tests__/better-auth.test.ts b/server/src/__tests__/better-auth.test.ts index 50aebc6dfad..31798ea7e2b 100644 --- a/server/src/__tests__/better-auth.test.ts +++ b/server/src/__tests__/better-auth.test.ts @@ -179,6 +179,35 @@ describe("Better Auth cookie scoping", () => { })).toBe(false); }); + it("disables secure cookies only for HTTP loopback requests in a managed HTTPS runtime", () => { + const managedRuntimeInput = { + deploymentMode: "authenticated", + deploymentExposure: "private", + authBaseUrlMode: "explicit", + authPublicBaseUrl: "https://worktree.example.test", + publicUrl: "https://worktree.example.test", + managedRuntimePublicUrl: "https://worktree.example.test", + } as const; + + expect(shouldDisableSecureAuthCookies({ + ...managedRuntimeInput, + requestUrl: "http://127.0.0.1:42013/api/auth/sign-in/email", + } as Parameters[0])).toBe(true); + expect(shouldDisableSecureAuthCookies({ + ...managedRuntimeInput, + requestUrl: "https://worktree.example.test/api/auth/sign-in/email", + } as Parameters[0])).toBe(false); + expect(shouldDisableSecureAuthCookies({ + ...managedRuntimeInput, + managedRuntimePublicUrl: undefined, + requestUrl: "http://127.0.0.1:42013/api/auth/sign-in/email", + } as Parameters[0])).toBe(false); + expect(shouldDisableSecureAuthCookies({ + ...managedRuntimeInput, + requestUrl: "http://board.example.test:42013/api/auth/sign-in/email", + } as Parameters[0])).toBe(false); + }); + it("adds hostname port variants for authenticated mode on non-default ports", () => { const trustedOrigins = deriveAuthTrustedOrigins({ deploymentMode: "authenticated", diff --git a/server/src/__tests__/managed-loopback-auth.test.ts b/server/src/__tests__/managed-loopback-auth.test.ts new file mode 100644 index 00000000000..9b3ee704606 --- /dev/null +++ b/server/src/__tests__/managed-loopback-auth.test.ts @@ -0,0 +1,101 @@ +import { createDb } from "@paperclipai/db"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import type { Config } from "../config.js"; +import { createBetterAuthInstance } from "../auth/better-auth.js"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping managed loopback auth tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +function authRequest(origin: string, path: string, init?: RequestInit): Request { + return new Request(`${origin}/api/auth${path}`, { + ...init, + headers: { + origin, + "content-type": "application/json", + ...init?.headers, + }, + }); +} + +function sessionCookie(response: Response): string { + const cookie = response.headers + .getSetCookie() + .find((value) => value.includes(".session_token=")); + expect(cookie).toBeDefined(); + return cookie!; +} + +describeEmbeddedPostgres("managed runtime loopback auth cookies", () => { + const publicOrigin = "https://worktree.example.test"; + const loopbackOrigin = "http://127.0.0.1:42013"; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-managed-loopback-auth-"); + }, 20_000); + + afterAll(async () => { + vi.unstubAllEnvs(); + await tempDb?.cleanup(); + }); + + it("uses a loopback sign-in cookie on the next request while keeping the public cookie secure", async () => { + vi.stubEnv("BETTER_AUTH_SECRET", "managed-loopback-auth-test-secret"); + vi.stubEnv("PAPERCLIP_MANAGED_RUNTIME_PUBLIC_URL", publicOrigin); + vi.stubEnv("PAPERCLIP_PUBLIC_URL", ""); + + const db = createDb(tempDb!.connectionString); + const config = { + deploymentMode: "authenticated", + deploymentExposure: "private", + authBaseUrlMode: "explicit", + authPublicBaseUrl: publicOrigin, + authDisableSignUp: false, + } as Config; + const auth = createBetterAuthInstance(db, config, [publicOrigin, loopbackOrigin]); + const credentials = { + name: "Loopback Operator", + email: "loopback-operator@example.test", + password: "correct-horse-battery-staple", + }; + + const signUpResponse = await auth.handler(authRequest(publicOrigin, "/sign-up/email", { + method: "POST", + body: JSON.stringify(credentials), + })); + expect(signUpResponse.status).toBe(200); + expect(sessionCookie(signUpResponse)).toMatch(/;\s*Secure(?:;|$)/i); + + const signInResponse = await auth.handler(authRequest(loopbackOrigin, "/sign-in/email", { + method: "POST", + body: JSON.stringify({ + email: credentials.email, + password: credentials.password, + }), + })); + expect(signInResponse.status).toBe(200); + const loopbackCookie = sessionCookie(signInResponse); + expect(loopbackCookie).not.toMatch(/;\s*Secure(?:;|$)/i); + + const getSessionResponse = await auth.handler(authRequest(loopbackOrigin, "/get-session", { + method: "GET", + headers: { + cookie: loopbackCookie.split(";", 1)[0], + }, + })); + expect(getSessionResponse.status).toBe(200); + await expect(getSessionResponse.json()).resolves.toMatchObject({ + user: { email: credentials.email }, + }); + }); +}); diff --git a/server/src/auth/better-auth.ts b/server/src/auth/better-auth.ts index c367ffd3ac7..6fd400a6ec3 100644 --- a/server/src/auth/better-auth.ts +++ b/server/src/auth/better-auth.ts @@ -92,11 +92,21 @@ export function shouldDisableSecureAuthCookies(input: { authBaseUrlMode: Config["authBaseUrlMode"]; authPublicBaseUrl: string | undefined; publicUrl?: string | undefined; + managedRuntimePublicUrl?: string | undefined; + requestUrl?: string | undefined; }): boolean { const publicUrl = ( input.publicUrl?.trim() || (input.authBaseUrlMode === "explicit" ? input.authPublicBaseUrl?.trim() : "") ); + if ( + input.deploymentMode === "authenticated" && + isHttpsUrl(publicUrl) && + isHttpsUrl(input.managedRuntimePublicUrl) && + isHttpLoopbackUrl(input.requestUrl) + ) { + return true; + } if (publicUrl) return publicUrl.startsWith("http://"); return ( @@ -108,6 +118,52 @@ export function shouldDisableSecureAuthCookies(input: { ); } +function isHttpsUrl(value: string | undefined): boolean { + if (!value) return false; + try { + return new URL(value).protocol === "https:"; + } catch { + return false; + } +} + +function isLoopbackHostname(hostname: string): boolean { + const normalized = hostname.trim().toLowerCase(); + return ( + normalized === "localhost" || + normalized === "127.0.0.1" || + normalized === "[::1]" || + normalized === "::1" + ); +} + +function isHttpLoopbackUrl(value: string | undefined): boolean { + if (!value) return false; + try { + const url = new URL(value); + return url.protocol === "http:" && isLoopbackHostname(url.hostname); + } catch { + return false; + } +} + +function requestUrlFromHeaders(headers: Headers): string | undefined { + const host = headers.get("host")?.trim(); + if (!host) return undefined; + + const forwardedProtocol = headers.get("x-forwarded-proto")?.split(",", 1)[0]?.trim().toLowerCase(); + const protocol = forwardedProtocol === "http" || forwardedProtocol === "https" + ? forwardedProtocol + : (() => { + try { + return isLoopbackHostname(new URL(`http://${host}`).hostname) ? "http" : "https"; + } catch { + return "https"; + } + })(); + return `${protocol}://${host}`; +} + function headersFromNodeHeaders(rawHeaders: IncomingHttpHeaders): Headers { const headers = new Headers(); for (const [key, raw] of Object.entries(rawHeaders)) { @@ -185,6 +241,7 @@ export function resolveWorkspaceHandoffIdentity( export function createBetterAuthInstance(db: Db, config: Config, trustedOrigins: string[]): BetterAuthInstance { const baseUrl = config.authBaseUrlMode === "explicit" ? config.authPublicBaseUrl : undefined; const publicUrl = process.env.PAPERCLIP_PUBLIC_URL?.trim() || baseUrl; + const managedRuntimePublicUrl = process.env.PAPERCLIP_MANAGED_RUNTIME_PUBLIC_URL?.trim() || undefined; const secret = process.env.BETTER_AUTH_SECRET ?? process.env.PAPERCLIP_AGENT_JWT_SECRET; if (!secret) { throw new Error( @@ -252,7 +309,48 @@ export function createBetterAuthInstance(db: Db, config: Config, trustedOrigins: delete (authConfig as { baseURL?: string }).baseURL; } - return betterAuth(authConfig); + const defaultAuth = betterAuth(authConfig); + const supportsManagedLoopbackAuth = Boolean( + !disableSecureCookies && + isHttpsUrl(publicUrl) && + isHttpsUrl(managedRuntimePublicUrl), + ); + if (!supportsManagedLoopbackAuth) return defaultAuth; + + // Better Auth fixes both the Secure attribute and the __Secure- name prefix + // when an instance is created. Keep the public instance unchanged and route + // only managed HTTP-loopback requests through a cookie-compatible instance. + const loopbackAuth = betterAuth({ + ...authConfig, + advanced: buildBetterAuthAdvancedOptions({ disableSecureCookies: true }), + }); + const cookieSecurityInput = { + deploymentMode: config.deploymentMode, + deploymentExposure: config.deploymentExposure, + authBaseUrlMode: config.authBaseUrlMode, + authPublicBaseUrl: config.authPublicBaseUrl, + publicUrl, + managedRuntimePublicUrl, + }; + + return { + handler: (request) => { + const auth = shouldDisableSecureAuthCookies({ + ...cookieSecurityInput, + requestUrl: request.url, + }) ? loopbackAuth : defaultAuth; + return auth.handler(request); + }, + api: { + getSession: (input) => { + const auth = shouldDisableSecureAuthCookies({ + ...cookieSecurityInput, + requestUrl: requestUrlFromHeaders(input.headers), + }) ? loopbackAuth : defaultAuth; + return auth.api.getSession(input); + }, + }, + }; } export function createBetterAuthHandler(auth: BetterAuthHandlerTarget): RequestHandler {