Files
gridpilot.gg/docs/architecture/shared/UNIFIED_AUTH_CONCEPT.md
2026-01-11 13:04:33 +01:00

21 KiB

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

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

'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 (
      <div className="min-h-screen flex items-center justify-center bg-deep-graphite">
        <LoadingState message="Verifying authentication..." />
      </div>
    );
  }

  // 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

'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 (
      <div className="min-h-screen flex items-center justify-center bg-deep-graphite">
        <LoadingState message="Verifying access..." />
      </div>
    );
  }

  if (!session || (requiredRoles.length > 0 && !requiredRoles.includes(session.role || ''))) {
    return null;
  }

  return <>{children}</>;
}

Step 4: Usage Examples

Public Route (No Protection)

// app/leagues/page.tsx
export default function LeaguesPage() {
  return <LeaguesList />;
}

Authenticated Route

// app/dashboard/layout.tsx
import { AuthLayout } from '@/lib/guards/AuthLayout';

export default function DashboardLayout({ children }: { children: ReactNode }) {
  return (
    <AuthLayout requireAuth={true}>
      <div className="min-h-screen bg-deep-graphite">
        {children}
      </div>
    </AuthLayout>
  );
}

// app/dashboard/page.tsx
export default function DashboardPage() {
  // No additional auth checks needed - layout handles it
  return <DashboardContent />;
}

Role-Protected Route

// app/admin/layout.tsx
import { RoleLayout } from '@/lib/guards/RoleLayout';

export default function AdminLayout({ children }: { children: ReactNode }) {
  return (
    <RoleLayout requiredRoles={['owner', 'admin']}>
      <div className="min-h-screen bg-deep-graphite">
        {children}
      </div>
    </RoleLayout>
  );
}

// app/admin/page.tsx
export default function AdminPage() {
  // No additional checks - layout handles role verification
  return <AdminDashboard />;
}

Scoped Route (League Admin)

// 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 (
    <AuthLayout requireAuth={true}>
      <LeagueAccessGuard leagueId={params.id}>
        <div className="min-h-screen bg-deep-graphite">
          {children}
        </div>
      </LeagueAccessGuard>
    </AuthLayout>
  );
}

Step 5: API Guard Cleanup

File: apps/api/src/domain/auth/AuthorizationGuard.ts

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<RequireRolesMetadata | undefined>(
      REQUIRE_ROLES_METADATA_KEY,
      [handler, controllerClass],
    ) ?? null;

    // 3. Get user identity from request (set by AuthenticationGuard)
    const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
    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

/**
 * 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.