Refactor session management and enhance OAuth2 callback handling

- 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.
This commit is contained in:
smueller
2026-07-22 14:36:14 +02:00
parent 1de6eac078
commit 946283dfba
4 changed files with 54 additions and 244 deletions

View File

@@ -3,7 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server';
import { exchangeCodeForToken, fetchDiscordUser } from '@/lib/discord-oauth';
import { env } from '@/lib/env';
import { redis } from '@/lib/redis';
import { createSession } from '@/lib/session';
import { applySessionCookie, createSession } from '@/lib/session';
function oauthStateKey(state: string): string {
return `webui:oauth:state:${state}`;
@@ -36,6 +36,7 @@ export async function GET(request: NextRequest) {
}
await redis.del(stateKey);
let cookieValue: string;
try {
const token = await exchangeCodeForToken(code);
const discordUser = await fetchDiscordUser(token.access_token);
@@ -47,14 +48,19 @@ export async function GET(request: NextRequest) {
avatar: discordUser.avatar ?? null
};
await createSession({
cookieValue = await createSession({
user,
accessToken: token.access_token,
tokenExpiresAt: Date.now() + token.expires_in * 1000
});
} catch {
} catch (error) {
console.error('OAuth callback failed', error);
return loginRedirect('oauth_failed');
}
return NextResponse.redirect(new URL('/dashboard', env.WEBUI_URL));
// Cookie must be set on the redirect response — cookies().set() alone is not
// reliably attached when returning NextResponse.redirect() from a route handler.
const response = NextResponse.redirect(new URL('/dashboard', env.WEBUI_URL));
applySessionCookie(response, cookieValue);
return response;
}

View File

@@ -1,7 +1,9 @@
import { NextResponse } from 'next/server';
import { destroySession } from '@/lib/session';
import { clearSessionCookie, destroySession } from '@/lib/session';
export async function POST() {
await destroySession();
return NextResponse.json({ ok: true });
const response = NextResponse.json({ ok: true });
clearSessionCookie(response);
return response;
}

View File

@@ -1,5 +1,6 @@
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';
@@ -52,20 +53,41 @@ function sessionRedisKey(sessionId: string): string {
return `${SESSION_KEY_PREFIX}${sessionId}`;
}
export async function createSession(payload: SessionPayload): Promise<void> {
const sessionId = randomBytes(32).toString('hex');
await redis.set(sessionRedisKey(sessionId), JSON.stringify(payload), 'EX', SESSION_TTL_SECONDS);
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE_NAME, buildCookieValue(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.NODE_ENV === 'production',
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;
@@ -93,7 +115,6 @@ export async function destroySession(): Promise<void> {
await redis.del(sessionRedisKey(sessionId));
}
}
cookieStore.delete(SESSION_COOKIE_NAME);
}
export async function requireSession(): Promise<SessionPayload> {