diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 7958ffd19cc..225ae9ffead 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -222,6 +222,8 @@ That repo-local env also sets: - `PAPERCLIP_WORKTREE_COLOR=` The server/UI use those values for worktree-specific branding such as the top banner and dynamically colored favicon. +Authenticated worktree servers also use the `PAPERCLIP_INSTANCE_ID` value to scope Better Auth cookie names. +Browser cookies are shared by host rather than port, so this prevents logging into one `127.0.0.1:` worktree from replacing another worktree server's session cookie. Print shell exports explicitly when needed: diff --git a/server/src/__tests__/better-auth.test.ts b/server/src/__tests__/better-auth.test.ts new file mode 100644 index 00000000000..79bfa3e75bb --- /dev/null +++ b/server/src/__tests__/better-auth.test.ts @@ -0,0 +1,43 @@ +import { afterEach, describe, expect, it } from "vitest"; +import type { BetterAuthOptions } from "better-auth"; +import { getCookies } from "better-auth/cookies"; +import { + buildBetterAuthAdvancedOptions, + deriveAuthCookiePrefix, +} from "../auth/better-auth.js"; + +const ORIGINAL_INSTANCE_ID = process.env.PAPERCLIP_INSTANCE_ID; + +afterEach(() => { + if (ORIGINAL_INSTANCE_ID === undefined) delete process.env.PAPERCLIP_INSTANCE_ID; + else process.env.PAPERCLIP_INSTANCE_ID = ORIGINAL_INSTANCE_ID; +}); + +describe("Better Auth cookie scoping", () => { + it("derives an instance-scoped cookie prefix", () => { + expect(deriveAuthCookiePrefix("default")).toBe("paperclip-default"); + expect(deriveAuthCookiePrefix("PAP-1601-worktree")).toBe("paperclip-PAP-1601-worktree"); + }); + + it("uses PAPERCLIP_INSTANCE_ID for the Better Auth cookie prefix", () => { + process.env.PAPERCLIP_INSTANCE_ID = "sat-worktree"; + + const advanced = buildBetterAuthAdvancedOptions({ disableSecureCookies: false }); + + expect(advanced).toEqual({ + cookiePrefix: "paperclip-sat-worktree", + }); + expect(getCookies({ advanced } as BetterAuthOptions).sessionToken.name).toBe( + "paperclip-sat-worktree.session_token", + ); + }); + + it("keeps local http auth cookies non-secure while preserving the scoped prefix", () => { + process.env.PAPERCLIP_INSTANCE_ID = "pap-worktree"; + + expect(buildBetterAuthAdvancedOptions({ disableSecureCookies: true })).toEqual({ + cookiePrefix: "paperclip-pap-worktree", + useSecureCookies: false, + }); + }); +}); diff --git a/server/src/auth/better-auth.ts b/server/src/auth/better-auth.ts index 881c3072ca5..402dad52036 100644 --- a/server/src/auth/better-auth.ts +++ b/server/src/auth/better-auth.ts @@ -11,6 +11,7 @@ import { authVerifications, } from "@paperclipai/db"; import type { Config } from "../config.js"; +import { resolvePaperclipInstanceId } from "../home-paths.js"; export type BetterAuthSessionUser = { id: string; @@ -25,6 +26,24 @@ export type BetterAuthSessionResult = { type BetterAuthInstance = ReturnType; +const AUTH_COOKIE_PREFIX_FALLBACK = "default"; +const AUTH_COOKIE_PREFIX_INVALID_SEGMENTS_RE = /[^a-zA-Z0-9_-]+/g; + +export function deriveAuthCookiePrefix(instanceId = resolvePaperclipInstanceId()): string { + const scopedInstanceId = instanceId + .trim() + .replace(AUTH_COOKIE_PREFIX_INVALID_SEGMENTS_RE, "-") + .replace(/^-+|-+$/g, "") || AUTH_COOKIE_PREFIX_FALLBACK; + return `paperclip-${scopedInstanceId}`; +} + +export function buildBetterAuthAdvancedOptions(input: { disableSecureCookies: boolean }) { + return { + cookiePrefix: deriveAuthCookiePrefix(), + ...(input.disableSecureCookies ? { useSecureCookies: false } : {}), + }; +} + function headersFromNodeHeaders(rawHeaders: IncomingHttpHeaders): Headers { const headers = new Headers(); for (const [key, raw] of Object.entries(rawHeaders)) { @@ -97,7 +116,7 @@ export function createBetterAuthInstance(db: Db, config: Config, trustedOrigins? requireEmailVerification: false, disableSignUp: config.authDisableSignUp, }, - ...(isHttpOnly ? { advanced: { useSecureCookies: false } } : {}), + advanced: buildBetterAuthAdvancedOptions({ disableSecureCookies: isHttpOnly }), }; if (!baseUrl) {