From 8a59ec59d8e6e05e4021a57c15c06d66eb81d006 Mon Sep 17 00:00:00 2001 From: m4 Date: Sat, 8 Aug 2026 08:06:51 +0800 Subject: [PATCH] feat(webui): parameterize session TTL and cookie maxAge Co-Authored-By: Claude Opus 4.7 --- src/app/api/auth/password/route.ts | 17 +++++++++--- src/lib/server/auth.test.ts | 43 ++++++++++++++++++++++++++++++ src/lib/server/auth.ts | 9 ++++--- 3 files changed, 62 insertions(+), 7 deletions(-) create mode 100644 src/lib/server/auth.test.ts diff --git a/src/app/api/auth/password/route.ts b/src/app/api/auth/password/route.ts index 0c99e29..e6e82c8 100644 --- a/src/app/api/auth/password/route.ts +++ b/src/app/api/auth/password/route.ts @@ -1,5 +1,9 @@ import { type NextRequest, NextResponse } from "next/server"; -import { AUTH_COOKIE_NAME, isAuthenticationEnabled } from "@/lib/auth"; +import { + AUTH_COOKIE_NAME, + isAuthenticationEnabled, + sessionTtlSeconds, +} from "@/lib/auth"; import { AuthConfigurationError, authCookieOptions, @@ -96,10 +100,17 @@ export async function POST(request: NextRequest) { } await updateAuthPassword(session.username, newPassword); const role = roleForUsername(session.username) ?? session.role; - const { token, payload } = createSession(session.username, role); + const { token, payload } = createSession( + session.username, + role, + sessionTtlSeconds() + ); await recordLogin(payload); const response = NextResponse.json({ ok: true }, { headers: NO_STORE }); - response.cookies.set({ ...authCookieOptions(), value: token }); + response.cookies.set({ + ...authCookieOptions(sessionTtlSeconds()), + value: token, + }); return response; } catch (error) { const message = diff --git a/src/lib/server/auth.test.ts b/src/lib/server/auth.test.ts new file mode 100644 index 0000000..3ad1377 --- /dev/null +++ b/src/lib/server/auth.test.ts @@ -0,0 +1,43 @@ +import { afterAll, describe, expect, it, vi } from "vitest"; + +vi.mock("server-only", () => ({})); + +const ORIGINAL_ENABLED = process.env.WEBUI_AUTH_ENABLED; +const ORIGINAL_SECRET = process.env.WEBUI_AUTH_SECRET; + +process.env.WEBUI_AUTH_ENABLED = "true"; +process.env.WEBUI_AUTH_SECRET = "test-secret-with-at-least-32-characters"; + +const { authCookieOptions, createSession, decodeSessionPayload } = + await import("./auth"); + +afterAll(() => { + if (ORIGINAL_ENABLED === undefined) delete process.env.WEBUI_AUTH_ENABLED; + else process.env.WEBUI_AUTH_ENABLED = ORIGINAL_ENABLED; + if (ORIGINAL_SECRET === undefined) delete process.env.WEBUI_AUTH_SECRET; + else process.env.WEBUI_AUTH_SECRET = ORIGINAL_SECRET; +}); + +describe("createSession", () => { + it("sets expiresAt from the given TTL and round-trips", () => { + const before = Math.floor(Date.now() / 1000); + const { token, payload } = createSession("alice", "admin", 60); + expect(payload.expiresAt).toBeGreaterThanOrEqual(before + 60); + expect(payload.expiresAt).toBeLessThanOrEqual(before + 65); + expect(decodeSessionPayload(token)).toEqual(payload); + }); +}); + +describe("authCookieOptions", () => { + it("omits maxAge for a session-scoped cookie", () => { + const options = authCookieOptions(null); + expect(options).not.toHaveProperty("maxAge"); + expect(options.name).toBe("evoscientist_webui_session"); + expect(options.httpOnly).toBe(true); + expect(options.path).toBe("/"); + }); + + it("sets maxAge when a TTL is given", () => { + expect(authCookieOptions(3600).maxAge).toBe(3600); + }); +}); diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index f57e5ca..8da7b53 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -117,7 +117,8 @@ function sign(payload: string, secret: string): string { export function createSession( username: string, - role: UserRole + role: UserRole, + ttlSeconds: number ): { token: string; payload: SessionPayload; @@ -128,7 +129,7 @@ export function createSession( username, role, loggedInAt: now.toISOString(), - expiresAt: Math.floor(now.getTime() / 1000) + sessionTtlSeconds(), + expiresAt: Math.floor(now.getTime() / 1000) + ttlSeconds, }; const encoded = encode(JSON.stringify(payload)); return { token: `${encoded}.${sign(encoded, secret)}`, payload }; @@ -170,14 +171,14 @@ export async function recordLogin(payload: SessionPayload): Promise { await fs.rename(temp, target); } -export function authCookieOptions() { +export function authCookieOptions(ttlSeconds: number | null) { return { name: AUTH_COOKIE_NAME, httpOnly: true, sameSite: "lax" as const, secure: process.env.NODE_ENV === "production", path: "/", - maxAge: sessionTtlSeconds(), + ...(ttlSeconds === null ? {} : { maxAge: ttlSeconds }), }; }