- Updated session management functions to include cookie handling for session creation and destruction. - Introduced applySessionCookie and clearSessionCookie functions for better cookie management in responses. - Enhanced error logging in the OAuth2 callback to improve debugging. - Revised PHASE-TRACKING.md to reflect changes in session handling and OAuth2 integration.
127 lines
3.7 KiB
TypeScript
127 lines
3.7 KiB
TypeScript
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<typeof SessionPayloadSchema>;
|
|
|
|
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<string> {
|
|
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<SessionPayload | null> {
|
|
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<void> {
|
|
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<SessionPayload> {
|
|
const session = await getSession();
|
|
if (!session) {
|
|
throw new SessionRequiredError();
|
|
}
|
|
return session;
|
|
}
|