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.
148 lines
4.4 KiB
TypeScript
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));
|
|
}
|