fix(#414): update session config to 7d absolute, 2h idle timeout
All checks were successful
ci/woodpecker/push/api Pipeline was successful
All checks were successful
ci/woodpecker/push/api Pipeline was successful
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -18,7 +18,12 @@ vi.mock("better-auth/adapters/prisma", () => ({
|
|||||||
prismaAdapter: (...args: unknown[]) => mockPrismaAdapter(...args),
|
prismaAdapter: (...args: unknown[]) => mockPrismaAdapter(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { isOidcEnabled, validateOidcConfig, createAuth } from "./auth.config";
|
import {
|
||||||
|
isOidcEnabled,
|
||||||
|
validateOidcConfig,
|
||||||
|
createAuth,
|
||||||
|
getTrustedOrigins,
|
||||||
|
} from "./auth.config";
|
||||||
|
|
||||||
describe("auth.config", () => {
|
describe("auth.config", () => {
|
||||||
// Store original env vars to restore after each test
|
// 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_CLIENT_SECRET;
|
||||||
delete process.env.OIDC_REDIRECT_URI;
|
delete process.env.OIDC_REDIRECT_URI;
|
||||||
delete process.env.NODE_ENV;
|
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(() => {
|
afterEach(() => {
|
||||||
@@ -281,4 +289,104 @@ describe("auth.config", () => {
|
|||||||
expect(mockGenericOAuth).not.toHaveBeenCalled();
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -130,6 +130,46 @@ function getOidcPlugins(): ReturnType<typeof genericOAuth>[] {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) {
|
export function createAuth(prisma: PrismaClient) {
|
||||||
// Validate OIDC configuration at startup - fail fast if misconfigured
|
// Validate OIDC configuration at startup - fail fast if misconfigured
|
||||||
validateOidcConfig();
|
validateOidcConfig();
|
||||||
@@ -144,15 +184,17 @@ export function createAuth(prisma: PrismaClient) {
|
|||||||
},
|
},
|
||||||
plugins: [...getOidcPlugins()],
|
plugins: [...getOidcPlugins()],
|
||||||
session: {
|
session: {
|
||||||
expiresIn: 60 * 60 * 24, // 24 hours
|
expiresIn: 60 * 60 * 24 * 7, // 7 days absolute max
|
||||||
updateAge: 60 * 60 * 24, // 24 hours
|
updateAge: 60 * 60 * 2, // 2 hours idle timeout (sliding window)
|
||||||
},
|
},
|
||||||
trustedOrigins: [
|
advanced: {
|
||||||
process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000",
|
defaultCookieAttributes: {
|
||||||
"http://localhost:3001", // API origin (dev)
|
httpOnly: true,
|
||||||
"https://app.mosaicstack.dev", // Production web
|
secure: process.env.NODE_ENV === "production",
|
||||||
"https://api.mosaicstack.dev", // Production API
|
sameSite: "lax" as const,
|
||||||
],
|
},
|
||||||
|
},
|
||||||
|
trustedOrigins: getTrustedOrigins(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user