middleware fix wip
This commit is contained in:
234
apps/website/lib/auth/AuthFlowRouter.ts
Normal file
234
apps/website/lib/auth/AuthFlowRouter.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user