Files
paperclip/server/src/auth/better-auth.ts
Dale Stubblefield ad55af04cc fix: disable secure cookies for HTTP deployments
Fixes login failing silently on authenticated + private deployments
served over plain HTTP (e.g. Tailscale, LAN). Users can sign up and
sign in, but the session cookie is rejected by the browser so they
are immediately redirected back to the login page.

Better Auth defaults to __Secure- prefixed cookies with the Secure
flag when NODE_ENV=production. Browsers silently reject Secure cookies
on non-HTTPS origins. This detects when PAPERCLIP_PUBLIC_URL uses
http:// and sets useSecureCookies: false so session cookies work
without HTTPS.
2026-03-08 22:00:51 -05:00

148 lines
4.4 KiB
TypeScript

import type { Request, RequestHandler } from "express";
import type { IncomingHttpHeaders } from "node:http";
import { betterAuth } 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";
export type BetterAuthSessionUser = {
id: string;
email?: string | null;
name?: string | null;
};
export type BetterAuthSessionResult = {
session: { id: string; userId: string } | null;
user: BetterAuthSessionUser | null;
};
type BetterAuthInstance = ReturnType<typeof betterAuth>;
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): string[] {
const baseUrl = config.authBaseUrlMode === "explicit" ? config.authPublicBaseUrl : undefined;
const trustedOrigins = new Set<string>();
if (baseUrl) {
try {
trustedOrigins.add(new URL(baseUrl).origin);
} catch {
// Better Auth will surface invalid base URL separately.
}
}
if (config.deploymentMode === "authenticated") {
for (const hostname of config.allowedHostnames) {
const trimmed = hostname.trim().toLowerCase();
if (!trimmed) continue;
trustedOrigins.add(`https://${trimmed}`);
trustedOrigins.add(`http://${trimmed}`);
}
}
return Array.from(trustedOrigins);
}
export function createBetterAuthInstance(db: Db, config: Config, trustedOrigins?: string[]): BetterAuthInstance {
const baseUrl = config.authBaseUrlMode === "explicit" ? config.authPublicBaseUrl : undefined;
const secret = process.env.BETTER_AUTH_SECRET ?? process.env.PAPERCLIP_AGENT_JWT_SECRET ?? "paperclip-dev-secret";
const effectiveTrustedOrigins = trustedOrigins ?? deriveAuthTrustedOrigins(config);
const publicUrl = process.env.PAPERCLIP_PUBLIC_URL ?? baseUrl;
const isHttpOnly = publicUrl ? publicUrl.startsWith("http://") : false;
const authConfig = {
baseURL: baseUrl,
secret,
trustedOrigins: effectiveTrustedOrigins,
database: drizzleAdapter(db, {
provider: "pg",
schema: {
user: authUsers,
session: authSessions,
account: authAccounts,
verification: authVerifications,
},
}),
emailAndPassword: {
enabled: true,
requireEmailVerification: false,
disableSignUp: config.authDisableSignUp,
},
...(isHttpOnly ? { advanced: { useSecureCookies: false } } : {}),
};
if (!baseUrl) {
delete (authConfig as { baseURL?: string }).baseURL;
}
return betterAuth(authConfig);
}
export function createBetterAuthHandler(auth: BetterAuthInstance): RequestHandler {
const handler = toNodeHandler(auth);
return (req, res, next) => {
void Promise.resolve(handler(req, res)).catch(next);
};
}
export async function resolveBetterAuthSessionFromHeaders(
auth: BetterAuthInstance,
headers: Headers,
): Promise<BetterAuthSessionResult | null> {
const api = (auth as unknown as { api?: { getSession?: (input: unknown) => Promise<unknown> } }).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: BetterAuthInstance,
req: Request,
): Promise<BetterAuthSessionResult | null> {
return resolveBetterAuthSessionFromHeaders(auth, headersFromExpressRequest(req));
}