mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 04:02:18 +01:00
fix(auth): support managed loopback session cookies
This commit is contained in:
1 parent
a5e12a6d8b
commit
b4b59388e5
3 files changed
+229
-1
No files matched your search
@@ -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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user