fix(auth): support managed loopback session cookies

This commit is contained in:
Dotta committed 2026-08-20 21:20:02 +00:00
1 parent a5e12a6d8b
commit b4b59388e5
3 files changed
+229 -1

No files matched your search

+29
View File
@@ -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<typeof shouldDisableSecureAuthCookies>[0])).toBe(true);
expect(shouldDisableSecureAuthCookies({
...managedRuntimeInput,
requestUrl: "https://worktree.example.test/api/auth/sign-in/email",
} as Parameters<typeof shouldDisableSecureAuthCookies>[0])).toBe(false);
expect(shouldDisableSecureAuthCookies({
...managedRuntimeInput,
managedRuntimePublicUrl: undefined,
requestUrl: "http://127.0.0.1:42013/api/auth/sign-in/email",
} as Parameters<typeof shouldDisableSecureAuthCookies>[0])).toBe(false);
expect(shouldDisableSecureAuthCookies({
...managedRuntimeInput,
requestUrl: "http://board.example.test:42013/api/auth/sign-in/email",
} as Parameters<typeof shouldDisableSecureAuthCookies>[0])).toBe(false);
});
it("adds hostname port variants for authenticated mode on non-default ports", () => {
const trustedOrigins = deriveAuthTrustedOrigins({
deploymentMode: "authenticated",
@@ -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<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | 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 },
});
});
});
+99 -1
View File
@@ -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 {