import { createHmac, randomBytes, timingSafeEqual } from 'crypto'; import { cookies } from 'next/headers'; import type { NextResponse } from 'next/server'; import { z } from 'zod'; import { SessionUserSchema } from '@nexumi/shared'; import { env } from './env'; import { redis } from './redis'; export const SESSION_COOKIE_NAME = 'nexumi_session'; const SESSION_KEY_PREFIX = 'webui:session:'; const SESSION_TTL_SECONDS = 60 * 60 * 24 * 7; const SessionPayloadSchema = z.object({ user: SessionUserSchema, accessToken: z.string().min(1), tokenExpiresAt: z.number().optional() }); export type SessionPayload = z.infer; export class SessionRequiredError extends Error { constructor() { super('Session required'); this.name = 'SessionRequiredError'; } } function sign(value: string): string { return createHmac('sha256', env.SESSION_SECRET).update(value).digest('hex'); } function buildCookieValue(sessionId: string): string { return `${sessionId}.${sign(sessionId)}`; } function parseCookieValue(cookieValue: string): string | null { const separatorIndex = cookieValue.lastIndexOf('.'); if (separatorIndex <= 0) { return null; } const sessionId = cookieValue.slice(0, separatorIndex); const signature = cookieValue.slice(separatorIndex + 1); const expected = sign(sessionId); const provided = Buffer.from(signature); const expectedBuffer = Buffer.from(expected); if (provided.length !== expectedBuffer.length || !timingSafeEqual(provided, expectedBuffer)) { return null; } return sessionId; } function sessionRedisKey(sessionId: string): string { return `${SESSION_KEY_PREFIX}${sessionId}`; } /** Secure cookies only when the public WebUI URL is HTTPS (HTTP IP deploys must not use Secure). */ export function sessionCookieOptions(): { httpOnly: boolean; sameSite: 'lax'; secure: boolean; path: string; maxAge: number; } { return { httpOnly: true, sameSite: 'lax', secure: env.WEBUI_URL.startsWith('https://'), path: '/', maxAge: SESSION_TTL_SECONDS }; } export function applySessionCookie(response: NextResponse, cookieValue: string): void { response.cookies.set(SESSION_COOKIE_NAME, cookieValue, sessionCookieOptions()); } export function clearSessionCookie(response: NextResponse): void { response.cookies.set(SESSION_COOKIE_NAME, '', { ...sessionCookieOptions(), maxAge: 0 }); } /** Persists the session in Redis and returns the signed cookie value to attach to a response. */ export async function createSession(payload: SessionPayload): Promise { const sessionId = randomBytes(32).toString('hex'); await redis.set(sessionRedisKey(sessionId), JSON.stringify(payload), 'EX', SESSION_TTL_SECONDS); return buildCookieValue(sessionId); } export async function getSession(): Promise { const cookieStore = await cookies(); const raw = cookieStore.get(SESSION_COOKIE_NAME)?.value; if (!raw) { return null; } const sessionId = parseCookieValue(raw); if (!sessionId) { return null; } const stored = await redis.get(sessionRedisKey(sessionId)); if (!stored) { return null; } const parsed = SessionPayloadSchema.safeParse(JSON.parse(stored)); return parsed.success ? parsed.data : null; } export async function destroySession(): Promise { const cookieStore = await cookies(); const raw = cookieStore.get(SESSION_COOKIE_NAME)?.value; if (raw) { const sessionId = parseCookieValue(raw); if (sessionId) { await redis.del(sessionRedisKey(sessionId)); } } } export async function requireSession(): Promise { const session = await getSession(); if (!session) { throw new SessionRequiredError(); } return session; }