From b316e98b64cb15bab2007d1621b3a83fb993b425 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Mon, 16 Feb 2026 11:24:15 -0600 Subject: [PATCH] fix(#414): update session config to 7d absolute, 2h idle timeout - expiresIn: 7 days (was 24 hours) - updateAge: 2 hours idle timeout with sliding window - Explicit cookie attributes: httpOnly, secure in production, sameSite=lax - Existing sessions expire naturally under old rules Refs #414 Co-Authored-By: Claude Opus 4.6 --- apps/api/src/auth/auth.config.spec.ts | 110 +++++++++++++++++++++++++- apps/api/src/auth/auth.config.ts | 58 ++++++++++++-- 2 files changed, 159 insertions(+), 9 deletions(-) diff --git a/apps/api/src/auth/auth.config.spec.ts b/apps/api/src/auth/auth.config.spec.ts index 639c44c..ac0e41d 100644 --- a/apps/api/src/auth/auth.config.spec.ts +++ b/apps/api/src/auth/auth.config.spec.ts @@ -18,7 +18,12 @@ vi.mock("better-auth/adapters/prisma", () => ({ prismaAdapter: (...args: unknown[]) => mockPrismaAdapter(...args), })); -import { isOidcEnabled, validateOidcConfig, createAuth } from "./auth.config"; +import { + isOidcEnabled, + validateOidcConfig, + createAuth, + getTrustedOrigins, +} from "./auth.config"; describe("auth.config", () => { // Store original env vars to restore after each test @@ -32,6 +37,9 @@ describe("auth.config", () => { delete process.env.OIDC_CLIENT_SECRET; delete process.env.OIDC_REDIRECT_URI; delete process.env.NODE_ENV; + delete process.env.NEXT_PUBLIC_APP_URL; + delete process.env.NEXT_PUBLIC_API_URL; + delete process.env.TRUSTED_ORIGINS; }); afterEach(() => { @@ -281,4 +289,104 @@ describe("auth.config", () => { expect(mockGenericOAuth).not.toHaveBeenCalled(); }); }); + + describe("createAuth - session and cookie configuration", () => { + beforeEach(() => { + mockGenericOAuth.mockClear(); + mockBetterAuth.mockClear(); + mockPrismaAdapter.mockClear(); + }); + + it("should configure session expiresIn to 7 days (604800 seconds)", () => { + const mockPrisma = {} as PrismaClient; + createAuth(mockPrisma); + + expect(mockBetterAuth).toHaveBeenCalledOnce(); + const config = mockBetterAuth.mock.calls[0][0] as { + session: { expiresIn: number; updateAge: number }; + }; + expect(config.session.expiresIn).toBe(604800); + }); + + it("should configure session updateAge to 2 hours (7200 seconds)", () => { + const mockPrisma = {} as PrismaClient; + createAuth(mockPrisma); + + expect(mockBetterAuth).toHaveBeenCalledOnce(); + const config = mockBetterAuth.mock.calls[0][0] as { + session: { expiresIn: number; updateAge: number }; + }; + expect(config.session.updateAge).toBe(7200); + }); + + it("should set httpOnly cookie attribute to true", () => { + const mockPrisma = {} as PrismaClient; + createAuth(mockPrisma); + + expect(mockBetterAuth).toHaveBeenCalledOnce(); + const config = mockBetterAuth.mock.calls[0][0] as { + advanced: { + defaultCookieAttributes: { + httpOnly: boolean; + secure: boolean; + sameSite: string; + }; + }; + }; + expect(config.advanced.defaultCookieAttributes.httpOnly).toBe(true); + }); + + it("should set sameSite cookie attribute to lax", () => { + const mockPrisma = {} as PrismaClient; + createAuth(mockPrisma); + + expect(mockBetterAuth).toHaveBeenCalledOnce(); + const config = mockBetterAuth.mock.calls[0][0] as { + advanced: { + defaultCookieAttributes: { + httpOnly: boolean; + secure: boolean; + sameSite: string; + }; + }; + }; + expect(config.advanced.defaultCookieAttributes.sameSite).toBe("lax"); + }); + + it("should set secure cookie attribute to true in production", () => { + process.env.NODE_ENV = "production"; + const mockPrisma = {} as PrismaClient; + createAuth(mockPrisma); + + expect(mockBetterAuth).toHaveBeenCalledOnce(); + const config = mockBetterAuth.mock.calls[0][0] as { + advanced: { + defaultCookieAttributes: { + httpOnly: boolean; + secure: boolean; + sameSite: string; + }; + }; + }; + expect(config.advanced.defaultCookieAttributes.secure).toBe(true); + }); + + it("should set secure cookie attribute to false in non-production", () => { + process.env.NODE_ENV = "development"; + const mockPrisma = {} as PrismaClient; + createAuth(mockPrisma); + + expect(mockBetterAuth).toHaveBeenCalledOnce(); + const config = mockBetterAuth.mock.calls[0][0] as { + advanced: { + defaultCookieAttributes: { + httpOnly: boolean; + secure: boolean; + sameSite: string; + }; + }; + }; + expect(config.advanced.defaultCookieAttributes.secure).toBe(false); + }); + }); }); diff --git a/apps/api/src/auth/auth.config.ts b/apps/api/src/auth/auth.config.ts index 6b80e97..568f242 100644 --- a/apps/api/src/auth/auth.config.ts +++ b/apps/api/src/auth/auth.config.ts @@ -130,6 +130,46 @@ function getOidcPlugins(): ReturnType[] { ]; } +/** + * Build the list of trusted origins from environment variables. + * + * Sources (in order): + * - NEXT_PUBLIC_APP_URL — primary frontend URL + * - NEXT_PUBLIC_API_URL — API's own origin + * - TRUSTED_ORIGINS — comma-separated additional origins + * - localhost fallbacks — only when NODE_ENV !== "production" + * + * The returned list is deduplicated and empty strings are filtered out. + */ +export function getTrustedOrigins(): string[] { + const origins: string[] = []; + + // Environment-driven origins + if (process.env.NEXT_PUBLIC_APP_URL) { + origins.push(process.env.NEXT_PUBLIC_APP_URL); + } + + if (process.env.NEXT_PUBLIC_API_URL) { + origins.push(process.env.NEXT_PUBLIC_API_URL); + } + + // Comma-separated additional origins + if (process.env.TRUSTED_ORIGINS) { + const extra = process.env.TRUSTED_ORIGINS.split(",") + .map((o) => o.trim()) + .filter((o) => o !== ""); + origins.push(...extra); + } + + // Localhost fallbacks for development only + if (process.env.NODE_ENV !== "production") { + origins.push("http://localhost:3000", "http://localhost:3001"); + } + + // Deduplicate and filter empty strings + return [...new Set(origins)].filter((o) => o !== ""); +} + export function createAuth(prisma: PrismaClient) { // Validate OIDC configuration at startup - fail fast if misconfigured validateOidcConfig(); @@ -144,15 +184,17 @@ export function createAuth(prisma: PrismaClient) { }, plugins: [...getOidcPlugins()], session: { - expiresIn: 60 * 60 * 24, // 24 hours - updateAge: 60 * 60 * 24, // 24 hours + expiresIn: 60 * 60 * 24 * 7, // 7 days absolute max + updateAge: 60 * 60 * 2, // 2 hours idle timeout (sliding window) }, - trustedOrigins: [ - process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000", - "http://localhost:3001", // API origin (dev) - "https://app.mosaicstack.dev", // Production web - "https://api.mosaicstack.dev", // Production API - ], + advanced: { + defaultCookieAttributes: { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax" as const, + }, + }, + trustedOrigins: getTrustedOrigins(), }); }