feat(webui): parameterize session TTL and cookie maxAge

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
m4
2026-08-08 08:06:51 +08:00
parent ed7de2a017
commit 8a59ec59d8
3 changed files with 62 additions and 7 deletions
+14 -3
View File
@@ -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 =
+43
View File
@@ -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);
});
});
+5 -4
View File
@@ -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<void> {
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 }),
};
}