middleware fix wip

This commit is contained in:
2026-01-04 12:49:30 +01:00
parent 729d95cd73
commit 691e6e2c7e
10 changed files with 741 additions and 152 deletions

View File

@@ -39,16 +39,30 @@ export default function LoginPage() {
// Check if user is already authenticated
useEffect(() => {
console.log('[LoginPage] useEffect running', {
session: session ? 'exists' : 'null',
returnTo: searchParams.get('returnTo'),
pathname: window.location.pathname,
search: window.location.search,
});
if (session) {
// Check if this is a returnTo redirect (user lacks permissions)
const isPermissionRedirect = searchParams.get('returnTo') !== null;
console.log('[LoginPage] Authenticated user check', {
isPermissionRedirect,
returnTo: searchParams.get('returnTo'),
});
if (isPermissionRedirect) {
// User was redirected here due to insufficient permissions
// Show permission error instead of redirecting
console.log('[LoginPage] Showing permission error');
setHasInsufficientPermissions(true);
} else {
// User navigated here directly while authenticated, redirect to dashboard
console.log('[LoginPage] Redirecting to dashboard');
router.replace('/dashboard');
}
}

View File

@@ -0,0 +1,234 @@
/**
* @file AuthFlowRouter.ts
* Intelligent authentication flow router
*
* Determines the correct action for any authentication scenario:
* - Public routes: allow access
* - Protected routes without session: redirect to login
* - Protected routes with session + correct role: allow access
* - Protected routes with session + wrong role: show permission error
*/
import { routes, routeMatchers, RouteGroup } from '@/lib/routing/RouteConfig';
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
const logger = new ConsoleLogger();
// ============================================================================
// CORE TYPES
// ============================================================================
export interface AuthSession {
userId: string;
role: string;
roles?: string[];
}
export interface LoginFlowInput {
session: AuthSession | null;
requestedPath: string;
}
// ============================================================================
// AUTH FLOW ROUTER
// ============================================================================
export class AuthFlowRouter {
private readonly _session: AuthSession | null;
private readonly _requestedPath: string;
private readonly _isPublic: boolean;
private readonly _requiredRoles: string[] | null;
private constructor(session: AuthSession | null, requestedPath: string) {
this._session = session;
this._requestedPath = requestedPath;
this._isPublic = routeMatchers.isPublic(requestedPath);
this._requiredRoles = routeMatchers.requiresRole(requestedPath);
}
/**
* Factory method
*/
static create(input: LoginFlowInput): AuthFlowRouter {
return new AuthFlowRouter(input.session, input.requestedPath);
}
/**
* Check if user has required roles
*/
private hasRequiredRoles(): boolean {
logger.info('[AuthFlowRouter] Checking required roles', {
hasSession: !!this._session,
requiredRoles: this._requiredRoles,
userRole: this._session?.role,
userRoles: this._session?.roles
});
if (!this._session) {
logger.info('[AuthFlowRouter] No session, returning false');
return false;
}
if (!this._requiredRoles) {
logger.info('[AuthFlowRouter] No required roles, returning true');
return true;
}
const userRoles = this._session.roles || [this._session.role];
const hasRoles = this._requiredRoles.some(role => userRoles.includes(role));
logger.info('[AuthFlowRouter] Role check result', {
userRoles,
requiredRoles: this._requiredRoles,
hasRoles
});
return hasRoles;
}
/**
* Get the action to take - pure function
*/
getAction(): AuthAction {
logger.info('[AuthFlowRouter] getAction called', {
requestedPath: this._requestedPath,
isPublic: this._isPublic,
hasSession: !!this._session,
requiredRoles: this._requiredRoles
});
// Public route - always accessible
if (this._isPublic) {
logger.info('[AuthFlowRouter] Public route, showing page');
return { type: AuthActionType.SHOW_PAGE };
}
// No session - must login
if (!this._session) {
logger.info('[AuthFlowRouter] No session, redirecting to login');
return {
type: AuthActionType.REDIRECT_TO_LOGIN,
returnTo: this._requestedPath
};
}
// Has session + has required roles
if (this.hasRequiredRoles()) {
logger.info('[AuthFlowRouter] Has required roles, redirecting to destination');
return {
type: AuthActionType.REDIRECT_TO_DESTINATION,
path: this._requestedPath
};
}
// Has session but missing roles
logger.info('[AuthFlowRouter] Missing required roles, showing permission error');
return {
type: AuthActionType.SHOW_PERMISSION_ERROR,
requestedPath: this._requestedPath,
userRoles: this._session.roles || [this._session.role],
requiredRoles: this._requiredRoles!
};
}
/**
* Get login redirect URL
*/
getLoginRedirectUrl(): string {
const action = this.getAction();
if (action.type === AuthActionType.REDIRECT_TO_LOGIN) {
const params = new URLSearchParams({ returnTo: action.returnTo });
return `${routes.auth.login}?${params.toString()}`;
}
if (action.type === AuthActionType.SHOW_PERMISSION_ERROR) {
const params = new URLSearchParams({ returnTo: action.requestedPath });
return `${routes.auth.login}?${params.toString()}`;
}
throw new Error("Not in login redirect state");
}
/**
* Get permission error message
*/
getPermissionError(): string {
const action = this.getAction();
if (action.type !== AuthActionType.SHOW_PERMISSION_ERROR) {
return `Access denied. Please log in with an account that has the required role.`;
}
const roleText = action.requiredRoles.join(' or ');
return `Access denied. Requires ${roleText} role. Your roles: ${action.userRoles.join(', ')}`;
}
/**
* Debug info
*/
getDebugInfo() {
return {
session: this._session ? { userId: this._session.userId, role: this._session.role } : null,
requestedPath: this._requestedPath,
isPublic: this._isPublic,
requiredRoles: this._requiredRoles,
action: this.getAction()
};
}
}
// ============================================================================
// ACTION TYPES
// ============================================================================
export enum AuthActionType {
SHOW_PAGE = "SHOW_PAGE",
REDIRECT_TO_LOGIN = "REDIRECT_TO_LOGIN",
REDIRECT_TO_DESTINATION = "REDIRECT_TO_DESTINATION",
SHOW_PERMISSION_ERROR = "SHOW_PERMISSION_ERROR"
}
export type AuthAction =
| { type: AuthActionType.SHOW_PAGE }
| { type: AuthActionType.REDIRECT_TO_LOGIN; returnTo: string }
| { type: AuthActionType.REDIRECT_TO_DESTINATION; path: string }
| { type: AuthActionType.SHOW_PERMISSION_ERROR; requestedPath: string; userRoles: string[]; requiredRoles: string[] };
// ============================================================================
// MIDDLEWARE HELPER
// ============================================================================
export function handleAuthFlow(
session: AuthSession | null,
requestedPath: string
): { shouldRedirect: boolean; redirectUrl?: string; shouldShowPage?: boolean } {
logger.info('[handleAuthFlow] Called', {
hasSession: !!session,
sessionRole: session?.role,
requestedPath
});
const router = AuthFlowRouter.create({ session, requestedPath });
const action = router.getAction();
logger.info('[handleAuthFlow] Action determined', {
actionType: action.type,
action: JSON.stringify(action, null, 2)
});
switch (action.type) {
case AuthActionType.SHOW_PAGE:
logger.info('[handleAuthFlow] Returning SHOW_PAGE');
return { shouldRedirect: false, shouldShowPage: true };
case AuthActionType.REDIRECT_TO_LOGIN:
const loginUrl = router.getLoginRedirectUrl();
logger.info('[handleAuthFlow] Returning REDIRECT_TO_LOGIN', { loginUrl });
return { shouldRedirect: true, redirectUrl: loginUrl };
case AuthActionType.REDIRECT_TO_DESTINATION:
logger.info('[handleAuthFlow] Returning REDIRECT_TO_DESTINATION');
return { shouldRedirect: false, shouldShowPage: true };
case AuthActionType.SHOW_PERMISSION_ERROR:
const errorUrl = router.getLoginRedirectUrl();
logger.info('[handleAuthFlow] Returning SHOW_PERMISSION_ERROR', { errorUrl });
return { shouldRedirect: true, redirectUrl: errorUrl };
}
}

View File

@@ -8,15 +8,15 @@ export class ConsoleLogger implements Logger {
}
debug(message: string, context?: unknown): void {
if (process.env.NODE_ENV === 'development') {
// Always log debug in development and test environments
if (process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test') {
console.debug(this.formatMessage('debug', message, context));
}
}
info(message: string, context?: unknown): void {
if (process.env.NODE_ENV === 'development') {
console.info(this.formatMessage('info', message, context));
}
// Always log info - we need transparency in all environments
console.info(this.formatMessage('info', message, context));
}
warn(message: string, context?: unknown): void {

View File

@@ -1,7 +1,7 @@
/**
* @file RouteConfig.ts
* Centralized routing configuration for clean, maintainable paths
*
*
* Design Principles:
* - Single source of truth for all routes
* - i18n-ready: paths can be localized
@@ -10,6 +10,10 @@
* - Environment-specific: can vary by mode
*/
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
const logger = new ConsoleLogger();
export interface RouteDefinition {
path: string;
name: string;
@@ -249,24 +253,41 @@ export const routeMatchers = {
* Check if path is public
*/
isPublic(path: string): boolean {
logger.info('[RouteConfig] isPublic check', { path });
const publicPatterns = this.getPublicPatterns();
// Check exact matches
if (publicPatterns.includes(path)) return true;
if (publicPatterns.includes(path)) {
logger.info('[RouteConfig] Path is public (exact match)', { path });
return true;
}
// Treat top-level detail pages as public (e2e relies on this)
// Examples: /leagues/:id, /races/:id, /drivers/:id, /teams/:id
const segments = path.split('/').filter(Boolean);
if (segments.length === 2) {
const [group, slug] = segments;
if (group === 'leagues' && slug !== 'create') return true;
if (group === 'races') return true;
if (group === 'drivers') return true;
if (group === 'teams') return true;
if (group === 'leagues' && slug !== 'create') {
logger.info('[RouteConfig] Path is public (league detail)', { path });
return true;
}
if (group === 'races') {
logger.info('[RouteConfig] Path is public (race detail)', { path });
return true;
}
if (group === 'drivers') {
logger.info('[RouteConfig] Path is public (driver detail)', { path });
return true;
}
if (group === 'teams') {
logger.info('[RouteConfig] Path is public (team detail)', { path });
return true;
}
}
// Check parameterized patterns
return publicPatterns.some(pattern => {
const isPublicParam = publicPatterns.some(pattern => {
if (pattern.includes('[')) {
const paramPattern = pattern.replace(/\[([^\]]+)\]/g, '([^/]+)');
const regex = new RegExp(`^${paramPattern}$`);
@@ -274,6 +295,14 @@ export const routeMatchers = {
}
return false;
});
if (isPublicParam) {
logger.info('[RouteConfig] Path is public (parameterized match)', { path });
} else {
logger.info('[RouteConfig] Path is NOT public', { path });
}
return isPublicParam;
},
/**
@@ -287,14 +316,20 @@ export const routeMatchers = {
* Check if path requires specific role
*/
requiresRole(path: string): string[] | null {
logger.info('[RouteConfig] requiresRole check', { path });
if (this.isInGroup(path, 'admin')) {
// Website session roles come from the API and are more specific than just "admin".
// Keep "admin"/"owner" for backwards compatibility.
return ['admin', 'owner', 'league-admin', 'league-steward', 'league-owner', 'system-owner', 'super-admin'];
const roles = ['admin', 'owner', 'league-admin', 'league-steward', 'league-owner', 'system-owner', 'super-admin'];
logger.info('[RouteConfig] Path requires admin roles', { path, roles });
return roles;
}
if (this.isInGroup(path, 'sponsor')) {
logger.info('[RouteConfig] Path requires sponsor role', { path });
return ['sponsor'];
}
logger.info('[RouteConfig] Path requires no specific role', { path });
return null;
},
};

View File

@@ -1,27 +1,33 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { SessionGateway } from '@/lib/gateways/SessionGateway';
import { routes, routeMatchers } from '@/lib/routing/RouteConfig';
import { handleAuthFlow } from '@/lib/auth/AuthFlowRouter';
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
import { routeMatchers } from '@/lib/routing/RouteConfig';
const logger = new ConsoleLogger();
/**
* Server-side route protection middleware
*
* This middleware provides comprehensive route protection by:
* 1. Setting x-pathname header for layout-level protection
* 2. Checking authentication status via SessionGateway
* 3. Redirecting unauthenticated users from protected routes
* 4. Redirecting authenticated users away from auth pages
* 5. Handling role-based access control
*
* Uses UnifiedLoginStateMachine for deterministic, type-safe authentication flow
*/
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const cookieHeader = request.headers.get('cookie') || '';
// Debug logging
console.log(`[MIDDLEWARE] Processing request for path: ${pathname}`);
logger.info('[MIDDLEWARE] ========== REQUEST START ==========');
logger.info('[MIDDLEWARE] Request details', {
pathname,
method: request.method,
url: request.url,
cookieHeaderLength: cookieHeader.length,
cookiePreview: cookieHeader.substring(0, 100) + (cookieHeader.length > 100 ? '...' : '')
});
// Handle /sponsor root redirect to /sponsor/dashboard in middleware to preserve cookies
// Handle /sponsor root redirect (preserves cookies)
if (pathname === '/sponsor') {
console.log(`[MIDDLEWARE] Redirecting /sponsor to /sponsor/dashboard`);
logger.info('[MIDDLEWARE] Redirecting /sponsor /sponsor/dashboard');
return NextResponse.redirect(new URL('/sponsor/dashboard', request.url));
}
@@ -29,118 +35,77 @@ export async function middleware(request: NextRequest) {
const response = NextResponse.next();
response.headers.set('x-pathname', pathname);
// Get session first (needed for all auth-related decisions)
// Get session
logger.info('[MIDDLEWARE] Fetching session...');
const sessionGateway = new SessionGateway();
const session = await sessionGateway.getSessionFromRequest(request);
console.log(`[MIDDLEWARE] Session retrieved:`, session ? 'Session found' : 'No session');
if (session) {
console.log(`[MIDDLEWARE] User role:`, session.user?.role);
logger.info('[MIDDLEWARE] Session fetched', {
hasSession: !!session,
userId: session?.user?.userId,
role: session?.user?.role,
sessionData: session ? JSON.stringify(session, null, 2) : 'null'
});
// Convert session to state machine format
const authSession = session ? {
userId: session.user?.userId || '',
role: session.user?.role || 'driver',
roles: session.user?.role ? [session.user.role] : ['driver']
} : null;
logger.info('[MIDDLEWARE] Auth session converted', {
authSession: authSession ? JSON.stringify(authSession, null, 2) : 'null'
});
// Debug: Log route classification
const isPublic = routeMatchers.isPublic(pathname);
const requiresRole = routeMatchers.requiresRole(pathname);
logger.info('[MIDDLEWARE] Route classification', {
path: pathname,
isPublic,
requiresRole
});
// Use state machine to determine action
let result;
try {
logger.info('[MIDDLEWARE] Calling handleAuthFlow...');
result = handleAuthFlow(authSession, pathname);
logger.info('[MIDDLEWARE] handleAuthFlow result', {
result: JSON.stringify(result, null, 2)
});
} catch (error) {
logger.error('[MIDDLEWARE] Error in auth flow', error instanceof Error ? error : new Error(String(error)));
// Fallback: redirect to login if there's an error
return NextResponse.redirect(new URL(`/auth/login?returnTo=${encodeURIComponent(pathname)}`, request.url));
}
logger.info('[MIDDLEWARE] Decision summary', {
pathname,
hasSession: !!authSession,
role: authSession?.role,
shouldRedirect: result.shouldRedirect,
redirectUrl: result.redirectUrl
});
if (result.shouldRedirect && result.redirectUrl) {
const redirectUrl = new URL(result.redirectUrl, request.url);
logger.info('[MIDDLEWARE] REDIRECTING', {
from: pathname,
to: redirectUrl.toString()
});
const redirectResponse = NextResponse.redirect(redirectUrl);
logger.info('[MIDDLEWARE] ========== REQUEST END (REDIRECT) ==========');
return redirectResponse;
}
// Auth pages (login, signup, etc.) - handle before public check
if (routeMatchers.isInGroup(pathname, 'auth')) {
if (session) {
// Check if user was redirected here due to insufficient permissions
const returnTo = request.nextUrl.searchParams.get('returnTo');
if (returnTo) {
// User has a session but insufficient permissions for the returnTo route
// Allow them to see the login page (they may need to switch accounts)
console.log(`[MIDDLEWARE] Authenticated user at login with returnTo, allowing access`);
return response;
}
// User is authenticated and navigated to auth page directly, redirect away
const role = session.user?.role || 'driver';
const redirectPath = getHomePathForRole(role);
// Preserve locale if present in the original path
const localeMatch = pathname.match(/^\/([a-z]{2})\//);
if (localeMatch) {
const locale = localeMatch[1];
return NextResponse.redirect(new URL(`/${locale}${redirectPath}`, request.url));
}
return NextResponse.redirect(new URL(redirectPath, request.url));
}
// Unauthenticated users can access auth pages
return response;
}
// Public routes (no auth required, but not auth pages)
if (routeMatchers.isPublic(pathname)) {
console.log(`[MIDDLEWARE] Route is public, allowing access`);
return response;
}
// Protected routes (require authentication)
if (!session) {
// No session, redirect to login
console.log(`[MIDDLEWARE] No session, redirecting to login`);
// Preserve locale if present in the path
const localeMatch = pathname.match(/^\/([a-z]{2})\//);
const locale = localeMatch ? localeMatch[1] : null;
const redirectUrl = new URL(routes.auth.login, request.url);
redirectUrl.searchParams.set('returnTo', pathname);
// If locale is present, include it in the redirect
if (locale) {
redirectUrl.pathname = `/${locale}${redirectUrl.pathname}`;
}
console.log(`[MIDDLEWARE] Redirecting to:`, redirectUrl.toString());
return NextResponse.redirect(redirectUrl);
}
// Role-based access control
const requiredRoles = routeMatchers.requiresRole(pathname);
console.log(`[MIDDLEWARE] Required roles for ${pathname}:`, requiredRoles);
if (requiredRoles) {
const userRole = session.user?.role;
console.log(`[MIDDLEWARE] User role:`, userRole);
if (!userRole || !requiredRoles.includes(userRole)) {
// User doesn't have required role or no role at all, redirect to login
console.log(`[MIDDLEWARE] User doesn't have required role, redirecting to login`);
// Preserve locale if present in the path
const localeMatch = pathname.match(/^\/([a-z]{2})\//);
const locale = localeMatch ? localeMatch[1] : null;
const redirectUrl = new URL(routes.auth.login, request.url);
redirectUrl.searchParams.set('returnTo', pathname);
if (locale) {
redirectUrl.pathname = `/${locale}${redirectUrl.pathname}`;
}
console.log(`[MIDDLEWARE] Redirecting to:`, redirectUrl.toString());
return NextResponse.redirect(redirectUrl);
}
}
// All checks passed, allow access
console.log(`[MIDDLEWARE] All checks passed, allowing access`);
// All checks passed
logger.info('[MIDDLEWARE] ALLOWING ACCESS', { pathname });
logger.info('[MIDDLEWARE] ========== REQUEST END (ALLOW) ==========');
return response;
}
/**
* Get the home path for a specific role
*/
function getHomePathForRole(role: string): string {
const roleHomeMap: Record<string, string> = {
'driver': routes.protected.dashboard,
'sponsor': routes.sponsor.dashboard,
'league-admin': routes.admin.root,
'league-steward': routes.admin.root,
'league-owner': routes.admin.root,
'system-owner': routes.admin.root,
'super-admin': routes.admin.root,
};
return roleHomeMap[role] || routes.protected.dashboard;
}
/**
* Configure which routes the middleware should run on
*/