# Unified Authentication & Authorization Architecture ## Executive Summary This document defines a **clean, predictable, and secure** authentication and authorization architecture that eliminates the current "fucking unpredictable mess" by establishing clear boundaries between server-side and client-side responsibilities. ## Current State Analysis ### What's Wrong 1. **Confusing Layers**: Middleware, RouteGuards, AuthGuards, Blockers, Gateways - unclear hierarchy 2. **Mixed Responsibilities**: Server and client both doing similar checks inconsistently 3. **Inconsistent Patterns**: Some routes use middleware, some use guards, some use both 4. **Role Confusion**: Frontend has role logic that should be server-only 5. **Debugging Nightmare**: Multiple layers with unclear flow ### What's Actually Working 1. **API Guards**: Clean NestJS pattern with `@Public()`, `@RequireRoles()` 2. **Basic Middleware**: Route protection works at edge 3. **Auth Context**: Session management exists 4. **Permission Model**: Documented in AUTHORIZATION.md ## Core Principle: Server as Source of Truth **Golden Rule**: The API server is the **single source of truth** for authentication and authorization. The client is a dumb terminal that displays what the server allows. ### Server-Side Responsibilities (API) #### 1. Authentication - ✅ **Session Validation**: Verify JWT/session cookies - ✅ **Identity Resolution**: Who is this user? - ✅ **Token Management**: Issue, refresh, revoke tokens - ❌ **UI Redirects**: Never redirect, return 401/403 #### 2. Authorization - ✅ **Role Verification**: Check user roles against requirements - ✅ **Permission Evaluation**: Check capabilities (view/mutate) - ✅ **Scope Resolution**: Determine league/sponsor/team context - ✅ **Access Denial**: Return 401/403 with clear messages - ❌ **Client State**: Never trust client-provided identity #### 3. Data Filtering - ✅ **Filter sensitive data**: Remove fields based on permissions - ✅ **Scope-based queries**: Only return data user can access - ❌ **Client-side filtering**: Never rely on frontend to hide data ### Client-Side Responsibilities (Website) #### 1. UX Enhancement - ✅ **Loading States**: Show "Verifying authentication..." - ✅ **Redirects**: Send unauthenticated users to login - ✅ **UI Hiding**: Hide buttons/links user can't access - ✅ **Feedback**: Show "Access denied" messages - ❌ **Security**: Never trust client checks for security #### 2. Session Management - ✅ **Session Cache**: Store session in context - ✅ **Auto-refresh**: Fetch session on app load - ✅ **Logout Flow**: Clear local state, call API logout - ❌ **Role Logic**: Don't make decisions based on roles #### 3. Route Protection - ✅ **Middleware**: Basic auth check at edge - ✅ **Layout Guards**: Verify session before rendering - ✅ **Page Guards**: Additional verification (defense in depth) - ❌ **Authorization**: Don't check permissions, let API fail ## Clean Architecture Layers ``` ┌─────────────────────────────────────────────────────────────┐ │ USER REQUEST │ └─────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ 1. EDGE MIDDLEWARE (Next.js) │ │ • Check for session cookie │ │ • Public routes: Allow through │ │ • Protected routes: Require auth cookie │ │ • Redirect to login if no cookie │ │ • NEVER check roles here │ └─────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ 2. API REQUEST (with session cookie) │ │ • NestJS AuthenticationGuard extracts user from session │ │ • Attaches user identity to request │ └─────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ 3. API AUTHORIZATION GUARD │ │ • Check route metadata: @Public(), @RequireRoles() │ │ • Evaluate permissions based on user identity │ │ • Return 401 (unauthenticated) or 403 (forbidden) │ │ • NEVER redirect, NEVER trust client identity │ └─────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ 4. API CONTROLLER │ │ • Execute business logic │ │ • Filter data based on permissions │ │ • Return appropriate response │ └─────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ 5. CLIENT RESPONSE HANDLING │ │ • 200: Render data │ │ • 401: Redirect to login with returnTo │ │ • 403: Show "Access denied" message │ │ • 404: Show "Not found" (for non-disclosure) │ └─────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ 6. COMPONENT RENDERING │ │ • Layout guards: Verify session exists │ │ • Route guards: Show loading → content or redirect │ │ • UI elements: Hide buttons user can't use │ └─────────────────────────────────────────────────────────────┘ ``` ## Implementation: Clean Route Protection ### Step 1: Simplify Middleware (Edge Layer) **File**: `apps/website/middleware.ts` ```typescript import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; /** * Edge Middleware - Simple and Predictable * * Responsibilities: * 1. Allow public routes (static assets, auth pages, discovery) * 2. Check for session cookie on protected routes * 3. Redirect to login if no cookie * 4. Let everything else through (API handles authorization) */ export function middleware(request: NextRequest) { const { pathname } = request.nextUrl; // 1. Always allow static assets and API routes if ( pathname.startsWith('/_next/') || pathname.startsWith('/api/') || pathname.match(/\.(svg|png|jpg|jpeg|gif|webp|ico|css|js)$/) ) { return NextResponse.next(); } // 2. Define public routes (no auth required) const publicRoutes = [ '/', '/auth/login', '/auth/signup', '/auth/forgot-password', '/auth/reset-password', '/auth/iracing', '/auth/iracing/start', '/auth/iracing/callback', '/leagues', '/drivers', '/teams', '/leaderboards', '/races', '/sponsor/signup', ]; // 3. Check if current route is public const isPublic = publicRoutes.includes(pathname) || publicRoutes.some(route => pathname.startsWith(route + '/')); if (isPublic) { // Special handling: redirect authenticated users away from auth pages const hasAuthCookie = request.cookies.has('gp_session'); const authRoutes = ['/auth/login', '/auth/signup', '/auth/forgot-password', '/auth/reset-password']; if (authRoutes.includes(pathname) && hasAuthCookie) { return NextResponse.redirect(new URL('/dashboard', request.url)); } return NextResponse.next(); } // 4. Protected routes: require session cookie const hasAuthCookie = request.cookies.has('gp_session'); if (!hasAuthCookie) { const loginUrl = new URL('/auth/login', request.url); loginUrl.searchParams.set('returnTo', pathname); return NextResponse.redirect(loginUrl); } // 5. User has cookie, let them through // API will handle actual authorization return NextResponse.next(); } export const config = { matcher: [ '/((?!_next/static|_next/image|_next/data|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|mp4|webm|mov|avi)$).*)', ], }; ``` ### Step 2: Clean Layout Guards (Client Layer) **File**: `apps/website/lib/guards/AuthLayout.tsx` ```typescript 'use client'; import { ReactNode, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { useAuth } from '@/lib/auth/AuthContext'; import { LoadingState } from '@/components/shared/LoadingState'; interface AuthLayoutProps { children: ReactNode; requireAuth?: boolean; redirectTo?: string; } /** * AuthLayout - Client-side session verification * * Responsibilities: * 1. Verify user session exists * 2. Show loading state while checking * 3. Redirect to login if no session * 4. Render children if authenticated * * Does NOT check permissions - that's the API's job */ export function AuthLayout({ children, requireAuth = true, redirectTo = '/auth/login' }: AuthLayoutProps) { const router = useRouter(); const { session, loading } = useAuth(); useEffect(() => { if (!requireAuth) return; // If done loading and no session, redirect if (!loading && !session) { const returnTo = window.location.pathname; router.push(`${redirectTo}?returnTo=${encodeURIComponent(returnTo)}`); } }, [loading, session, router, requireAuth, redirectTo]); // Show loading state if (loading) { return (
); } // Show nothing while redirecting (or show error if not redirecting) if (requireAuth && !session) { return null; } // Render protected content return <>{children}; } ``` ### Step 3: Role-Based Layout (Client Layer) **File**: `apps/website/lib/guards/RoleLayout.tsx` ```typescript 'use client'; import { ReactNode, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { useAuth } from '@/lib/auth/AuthContext'; import { LoadingState } from '@/components/shared/LoadingState'; interface RoleLayoutProps { children: ReactNode; requiredRoles: string[]; redirectTo?: string; } /** * RoleLayout - Client-side role verification * * Responsibilities: * 1. Verify user session exists * 2. Show loading state * 3. Redirect if no session OR insufficient role * 4. Render children if authorized * * Note: This is UX enhancement. API is still source of truth. */ export function RoleLayout({ children, requiredRoles, redirectTo = '/auth/login' }: RoleLayoutProps) { const router = useRouter(); const { session, loading } = useAuth(); useEffect(() => { if (loading) return; // No session? Redirect if (!session) { const returnTo = window.location.pathname; router.push(`${redirectTo}?returnTo=${encodeURIComponent(returnTo)}`); return; } // Has session but wrong role? Redirect if (requiredRoles.length > 0 && !requiredRoles.includes(session.role || '')) { // Could redirect to dashboard or show access denied router.push('/dashboard'); return; } }, [loading, session, router, requiredRoles, redirectTo]); if (loading) { return (
); } if (!session || (requiredRoles.length > 0 && !requiredRoles.includes(session.role || ''))) { return null; } return <>{children}; } ``` ### Step 4: Usage Examples #### Public Route (No Protection) ```typescript // app/leagues/page.tsx export default function LeaguesPage() { return ; } ``` #### Authenticated Route ```typescript // app/dashboard/layout.tsx import { AuthLayout } from '@/lib/guards/AuthLayout'; export default function DashboardLayout({ children }: { children: ReactNode }) { return (
{children}
); } // app/dashboard/page.tsx export default function DashboardPage() { // No additional auth checks needed - layout handles it return ; } ``` #### Role-Protected Route ```typescript // app/admin/layout.tsx import { RoleLayout } from '@/lib/guards/RoleLayout'; export default function AdminLayout({ children }: { children: ReactNode }) { return (
{children}
); } // app/admin/page.tsx export default function AdminPage() { // No additional checks - layout handles role verification return ; } ``` #### Scoped Route (League Admin) ```typescript // app/leagues/[id]/settings/layout.tsx import { AuthLayout } from '@/lib/guards/AuthLayout'; import { LeagueAccessGuard } from '@/components/leagues/LeagueAccessGuard'; export default function LeagueSettingsLayout({ children, params }: { children: ReactNode; params: { id: string }; }) { return (
{children}
); } ``` ### Step 5: API Guard Cleanup **File**: `apps/api/src/domain/auth/AuthorizationGuard.ts` ```typescript import { CanActivate, ExecutionContext, ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { AuthorizationService } from './AuthorizationService'; import { PUBLIC_ROUTE_METADATA_KEY } from './Public'; import { REQUIRE_ROLES_METADATA_KEY, RequireRolesMetadata } from './RequireRoles'; type AuthenticatedRequest = { user?: { userId: string }; }; @Injectable() export class AuthorizationGuard implements CanActivate { constructor( private readonly reflector: Reflector, private readonly authorizationService: AuthorizationService, ) {} canActivate(context: ExecutionContext): boolean { const handler = context.getHandler(); const controllerClass = context.getClass(); // 1. Check if route is public const isPublic = this.reflector.getAllAndOverride<{ public: true } | undefined>( PUBLIC_ROUTE_METADATA_KEY, [handler, controllerClass], )?.public ?? false; if (isPublic) { return true; } // 2. Get required roles const rolesMetadata = this.reflector.getAllAndOverride( REQUIRE_ROLES_METADATA_KEY, [handler, controllerClass], ) ?? null; // 3. Get user identity from request (set by AuthenticationGuard) const request = context.switchToHttp().getRequest(); const userId = request.user?.userId; // 4. Deny if not authenticated if (!userId) { throw new UnauthorizedException('Authentication required'); } // 5. If no roles required, allow if (!rolesMetadata || rolesMetadata.anyOf.length === 0) { return true; } // 6. Check if user has required role const userRoles = this.authorizationService.getRolesForUser(userId); const hasAnyRole = rolesMetadata.anyOf.some((r) => userRoles.includes(r)); if (!hasAnyRole) { throw new ForbiddenException(`Access requires one of: ${rolesMetadata.anyOf.join(', ')}`); } return true; } } ``` ### Step 6: Client Error Handling **File**: `apps/website/lib/api/client.ts` ```typescript /** * API Client with unified error handling */ export async function apiFetch(url: string, options: RequestInit = {}) { const response = await fetch(url, { ...options, credentials: 'include', headers: { 'Content-Type': 'application/json', ...options.headers, }, }); // Handle authentication errors if (response.status === 401) { // Session expired or invalid window.location.href = '/auth/login?returnTo=' + encodeURIComponent(window.location.pathname); throw new Error('Authentication required'); } // Handle authorization errors if (response.status === 403) { const error = await response.json().catch(() => ({ message: 'Access denied' })); throw new Error(error.message || 'You do not have permission to access this resource'); } // Handle not found if (response.status === 404) { throw new Error('Resource not found'); } // Handle server errors if (response.status >= 500) { throw new Error('Server error. Please try again later.'); } return response; } ``` ## Benefits of This Architecture ### 1. **Clear Responsibilities** - Server: Security and authorization - Client: UX and user experience ### 2. **Predictable Flow** ``` User → Middleware → API → Guard → Controller → Response → Client ``` ### 3. **Easy Debugging** - Check middleware logs - Check API guard logs - Check client session state ### 4. **Secure by Default** - API never trusts client - Client never makes security decisions - Defense in depth without confusion ### 5. **Scalable** - Easy to add new routes - Easy to add new roles - Easy to add new scopes ## Migration Plan ### Phase 1: Clean Up Middleware (1 day) - [ ] Simplify `middleware.ts` to only check session cookie - [ ] Remove role logic from middleware - [ ] Define clear public routes list ### Phase 2: Create Clean Guards (2 days) - [ ] Create `AuthLayout` component - [ ] Create `RoleLayout` component - [ ] Create `ScopedLayout` component - [ ] Remove old RouteGuard/AuthGuard complexity ### Phase 3: Update Route Layouts (2 days) - [ ] Update all protected route layouts - [ ] Remove redundant page-level checks - [ ] Test all redirect flows ### Phase 4: API Guard Enhancement (1 day) - [ ] Ensure all endpoints have proper decorators - [ ] Add missing `@Public()` or `@RequireRoles()` - [ ] Test 401/403 responses ### Phase 5: Documentation & Testing (1 day) - [ ] Update all route protection docs - [ ] Create testing checklist - [ ] Verify all scenarios work ## Testing Checklist ### Unauthenticated User - [ ] `/dashboard` → Redirects to `/auth/login?returnTo=/dashboard` - [ ] `/profile` → Redirects to `/auth/login?returnTo=/profile` - [ ] `/admin` → Redirects to `/auth/login?returnTo=/admin` - [ ] `/leagues` → Works (public) - [ ] `/auth/login` → Works (public) ### Authenticated User (Regular) - [ ] `/dashboard` → Works - [ ] `/profile` → Works - [ ] `/admin` → Redirects to `/dashboard` (no role) - [ ] `/leagues` → Works (public) - [ ] `/auth/login` → Redirects to `/dashboard` ### Authenticated User (Admin) - [ ] `/dashboard` → Works - [ ] `/profile` → Works - [ ] `/admin` → Works - [ ] `/admin/users` → Works - [ ] `/leagues` → Works (public) ### Session Expiry - [ ] Navigate to protected route with expired session → Redirect to login - [ ] Return to original route after login → Works ### API Direct Calls - [ ] Call protected endpoint without auth → 401 - [ ] Call admin endpoint without role → 403 - [ ] Call public endpoint → 200 ## Summary This architecture eliminates the "fucking unpredictable mess" by: 1. **One Source of Truth**: API server handles all security 2. **Clear Layers**: Middleware → API → Guards → Controller 3. **Simple Client**: UX enhancement only, no security decisions 4. **Predictable Flow**: Always the same path for every request 5. **Easy to Debug**: Each layer has one job The result: **Clean, predictable, secure authentication and authorization that just works.**