import type { Request, RequestHandler } from "express"; import type { IncomingHttpHeaders } from "node:http"; import { betterAuth, type Auth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { toNodeHandler } from "better-auth/node"; import type { Db } from "@paperclipai/db"; import { authAccounts, authSessions, authUsers, authVerifications, } from "@paperclipai/db"; import type { Config } from "../config.js"; import { resolvePaperclipInstanceId } from "../home-paths.js"; import { workspaceLoginHandoffPlugin, type WorkspaceHandoffExpectedIdentity, } from "./workspace-login-handoff-plugin.js"; import { normalizeWorkspaceHandoffOrigin, resolveWorkspaceHandoffLocalCompanyId, resolveWorkspaceHandoffLocalKey, resolveWorkspaceHandoffLocalWorkspaceId, } from "./workspace-login-handoff.js"; export type BetterAuthSessionUser = { id: string; email?: string | null; name?: string | null; }; export type BetterAuthSessionResult = { session: { id: string; userId: string } | null; user: BetterAuthSessionUser | null; }; type BetterAuthGetSessionApi = { getSession?: (input: { headers: Headers }) => Promise; }; type BetterAuthHandlerTarget = Extract[0], { handler: Auth["handler"] }>; type BetterAuthSessionResolver = { api?: BetterAuthGetSessionApi; }; type BetterAuthInstance = BetterAuthHandlerTarget & BetterAuthSessionResolver; 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 } : {}), }; } export function shouldEnableAuthRateLimit(input: { deploymentMode: Config["deploymentMode"]; deploymentExposure?: Config["deploymentExposure"]; override?: string | undefined; }): boolean { const override = input.override?.trim().toLowerCase(); if (override === "true") return true; if (override === "false") return false; return input.deploymentMode === "authenticated"; } export function buildBetterAuthRateLimitOptions(input: { deploymentMode: Config["deploymentMode"]; deploymentExposure?: Config["deploymentExposure"]; override?: string | undefined; }) { return { enabled: shouldEnableAuthRateLimit(input), }; } export function shouldDisableSecureAuthCookies(input: { deploymentMode: Config["deploymentMode"]; deploymentExposure?: Config["deploymentExposure"]; 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 ( input.deploymentMode === "authenticated" && ( (input.deploymentExposure === "private" && input.authBaseUrlMode === "auto") || input.deploymentExposure === undefined ) ); } 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)) { if (!raw) continue; if (Array.isArray(raw)) { for (const value of raw) headers.append(key, value); continue; } headers.set(key, raw); } return headers; } function headersFromExpressRequest(req: Request): Headers { return headersFromNodeHeaders(req.headers); } export function deriveAuthTrustedOrigins(config: Config, opts?: { listenPort?: number }): string[] { const baseUrl = config.authBaseUrlMode === "explicit" ? config.authPublicBaseUrl : undefined; const trustedOrigins = new Set(); if (baseUrl) { try { trustedOrigins.add(new URL(baseUrl).origin); } catch { // Better Auth will surface invalid base URL separately. } } if (config.deploymentMode === "authenticated") { const port = opts?.listenPort ?? config.port; const needsPortVariants = port !== 80 && port !== 443; for (const hostname of config.allowedHostnames) { const trimmed = hostname.trim().toLowerCase(); if (!trimmed) continue; trustedOrigins.add(`https://${trimmed}`); trustedOrigins.add(`http://${trimmed}`); if (needsPortVariants) { trustedOrigins.add(`https://${trimmed}:${port}`); trustedOrigins.add(`http://${trimmed}:${port}`); } } } return Array.from(trustedOrigins); } /** * Identity a managed workspace instance compares an inbound handoff ticket * against. Every field comes from persisted configuration or injected runtime * identity — never from request headers — so a spoofed `X-Forwarded-Host` or * Tailscale identity header cannot retarget a ticket. Returns null when this * process was not started as a managed workspace, which leaves the exchange * endpoint unregistered. */ export function resolveWorkspaceHandoffIdentity( config: Config, env: NodeJS.ProcessEnv = process.env, ): WorkspaceHandoffExpectedIdentity | null { const key = resolveWorkspaceHandoffLocalKey(env); if (!key) return null; const configuredOrigin = normalizeWorkspaceHandoffOrigin(env.PAPERCLIP_PUBLIC_URL) ?? (config.authBaseUrlMode === "explicit" ? normalizeWorkspaceHandoffOrigin(config.authPublicBaseUrl) : null); return { key, instanceId: resolvePaperclipInstanceId(), executionWorkspaceId: resolveWorkspaceHandoffLocalWorkspaceId(env), companyId: resolveWorkspaceHandoffLocalCompanyId(env), origin: configuredOrigin, }; } 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( "BETTER_AUTH_SECRET (or PAPERCLIP_AGENT_JWT_SECRET) must be set. " + "For local development, set BETTER_AUTH_SECRET=paperclip-dev-secret in your .env file.", ); } const disableSecureCookies = shouldDisableSecureAuthCookies({ deploymentMode: config.deploymentMode, deploymentExposure: config.deploymentExposure, authBaseUrlMode: config.authBaseUrlMode, authPublicBaseUrl: config.authPublicBaseUrl, publicUrl, }); const authConfig = { baseURL: baseUrl, secret, trustedOrigins, database: drizzleAdapter(db, { provider: "pg", schema: { user: authUsers, session: authSessions, account: authAccounts, verification: authVerifications, }, }), emailAndPassword: { enabled: true, requireEmailVerification: false, disableSignUp: config.authDisableSignUp, }, rateLimit: buildBetterAuthRateLimitOptions({ deploymentMode: config.deploymentMode, deploymentExposure: config.deploymentExposure, override: process.env.PAPERCLIP_AUTH_RATE_LIMIT_ENABLED, }), advanced: buildBetterAuthAdvancedOptions({ disableSecureCookies }), // Registered only for a managed workspace instance: the plugin is what makes // `Open workspace` password-independent, and a control-plane instance that // was never handed a workspace key must not expose the exchange at all. ...(resolveWorkspaceHandoffIdentity(config) ? { plugins: [ workspaceLoginHandoffPlugin({ db, // Re-resolved per exchange so a hot restart cannot keep validating // against an origin the control plane has since republished. resolveExpectedIdentity: () => resolveWorkspaceHandoffIdentity(config) ?? { key: null, instanceId: null, executionWorkspaceId: null, companyId: null, origin: null, }, }), ], } : {}), }; if (!baseUrl) { delete (authConfig as { baseURL?: string }).baseURL; } 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 { const handler = toNodeHandler(auth); return (req, res, next) => { void Promise.resolve(handler(req, res)).catch(next); }; } export async function resolveBetterAuthSessionFromHeaders( auth: BetterAuthSessionResolver, headers: Headers, ): Promise { const api = auth.api; if (!api?.getSession) return null; const sessionValue = await api.getSession({ headers, }); if (!sessionValue || typeof sessionValue !== "object") return null; const value = sessionValue as { session?: { id?: string; userId?: string } | null; user?: { id?: string; email?: string | null; name?: string | null } | null; }; const session = value.session?.id && value.session.userId ? { id: value.session.id, userId: value.session.userId } : null; const user = value.user?.id ? { id: value.user.id, email: value.user.email ?? null, name: value.user.name ?? null, } : null; if (!session || !user) return null; return { session, user }; } export async function resolveBetterAuthSession( auth: BetterAuthSessionResolver, req: Request, ): Promise { return resolveBetterAuthSessionFromHeaders(auth, headersFromExpressRequest(req)); }