Files
gridpilot.gg/docs/architecture/CLEAN_AUTH_SOLUTION.md
2026-01-03 02:42:47 +01:00

9.7 KiB

Clean Authentication & Authorization Solution

Overview

This document describes the clean, predictable, and maintainable authentication and authorization architecture that replaces the previous "fucking unpredictable mess."

The Problem

Before:

  • Multiple overlapping protection layers (middleware, RouteGuard, AuthGuard, Blockers, Gateways)
  • Hardcoded paths scattered throughout codebase
  • Mixed responsibilities between server and client
  • Inconsistent patterns across routes
  • Role logic in both client and server
  • Debugging nightmare with unclear flow

The Solution

Core Principle: Single Source of Truth

All routing decisions flow through one centralized configuration system:

// apps/website/lib/routing/RouteConfig.ts
export const routes = {
  auth: {
    login: '/auth/login',
    signup: '/auth/signup',
    // ... all auth routes
  },
  public: {
    home: '/',
    leagues: '/leagues',
    // ... all public routes
  },
  protected: {
    dashboard: '/dashboard',
    // ... all protected routes
  },
  sponsor: {
    dashboard: '/sponsor/dashboard',
    // ... sponsor routes
  },
  admin: {
    root: '/admin',
    users: '/admin/users',
  },
  league: {
    detail: (id: string) => `/leagues/${id}`,
    // ... parameterized routes
  },
  // ... etc
};

Architecture Layers

1. Edge Middleware (Simple & Clean)

// apps/website/middleware.ts
export function middleware(request: NextRequest) {
  const hasAuthCookie = request.cookies.has('gp_session');
  
  // Public routes from config
  const publicRoutes = [
    routes.public.home,
    routes.public.leagues,
    routes.auth.login,
    // ... etc
  ];
  
  if (publicRoutes.includes(pathname)) {
    // Handle auth route redirects
    return NextResponse.next();
  }
  
  // Protected routes
  if (!hasAuthCookie) {
    const loginUrl = new URL(routes.auth.login, request.url);
    loginUrl.searchParams.set('returnTo', pathname);
    return NextResponse.redirect(loginUrl);
  }
  
  return NextResponse.next();
}

Responsibilities:

  • Check session cookie
  • Allow public routes
  • Redirect to login if no cookie
  • No role checking
  • No hardcoded paths

2. Client Guards (UX Enhancement)

// apps/website/lib/guards/AuthGuard.tsx
export function AuthGuard({ children, requireAuth = true }: AuthGuardProps) {
  const { session, loading } = useAuth();
  const router = useRouter();

  useEffect(() => {
    if (requireAuth && !loading && !session) {
      const url = new URL(routes.auth.login, window.location.origin);
      url.searchParams.set('returnTo', window.location.pathname);
      router.push(url.toString());
    }
  }, [session, loading]);

  if (loading) return <LoadingState />;
  if (!session && requireAuth) return null;
  return <>{children}</>;
}

// apps/website/lib/guards/RoleGuard.tsx
export function RoleGuard({ children, requiredRoles }: RoleGuardProps) {
  const { session, loading } = useAuth();
  const router = useRouter();

  useEffect(() => {
    if (!loading && session && !requiredRoles.includes(session.role)) {
      router.push(routes.protected.dashboard);
    }
  }, [session, loading]);

  if (loading) return <LoadingState />;
  if (!session || !requiredRoles.includes(session.role)) return null;
  return <>{children}</>;
}

Responsibilities:

  • Verify session exists
  • Show loading states
  • Redirect if unauthorized
  • Hide UI elements
  • Make security decisions

3. API Guards (Source of Truth)

// apps/api/src/domain/auth/AuthorizationGuard.ts
@Injectable()
export class AuthorizationGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    const isPublic = this.reflector.getMetadata('public', handler);
    if (isPublic) return true;

    const request = context.switchToHttp().getRequest();
    const userId = request.user?.userId;

    if (!userId) {
      throw new UnauthorizedException('Authentication required');
    }

    const rolesMetadata = this.reflector.getMetadata('roles', handler);
    if (rolesMetadata) {
      const userRoles = this.authorizationService.getRolesForUser(userId);
      const hasRole = rolesMetadata.some(r => userRoles.includes(r));
      if (!hasRole) {
        throw new ForbiddenException('Access denied');
      }
    }

    return true;
  }
}

Responsibilities:

  • Verify authentication
  • Check permissions
  • Return 401/403
  • Redirect
  • Trust client

Usage Examples

Public Route

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

Authenticated Route

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

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

// app/dashboard/page.tsx
export default function DashboardPage() {
  return <DashboardContent />;
}

Role-Protected Route

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

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

Scoped Route (League Admin)

// app/leagues/[id]/settings/layout.tsx
import { AuthGuard } from '@/lib/guards/AuthGuard';
import { LeagueAccessGuard } from '@/components/leagues/LeagueAccessGuard';

export default function LeagueSettingsLayout({ children, params }) {
  return (
    <AuthGuard>
      <LeagueAccessGuard leagueId={params.id}>
        <div className="min-h-screen bg-deep-graphite">
          {children}
        </div>
      </LeagueAccessGuard>
    </AuthGuard>
  );
}

API Endpoint

// apps/api/src/domain/league/LeagueController.ts
@Controller('leagues')
export class LeagueController {
  @Get(':leagueId/admin')
  @RequireAuthenticatedUser()
  @RequireRoles('admin')
  getLeagueAdmin(@Param('leagueId') leagueId: string) {
    // Service verifies league-specific permissions
    return this.leagueService.getAdminData(leagueId);
  }
}

Benefits

1. Predictable Flow

User Request → Middleware (check cookie) → API (auth + authz) → Controller → Response → Client (handle errors)

2. Easy Debugging

# Check middleware
curl -I http://localhost:3000/dashboard

# Check API auth
curl -I http://localhost:3000/api/admin/users \
  -H "Cookie: gp_session=token"

# Check client session
# Browser console: console.log(useAuth().session)

3. i18n Ready

// Future: Switch locales by changing config
const routesDe = { ...routes, auth: { login: '/de/auth/login' } };
const routesEs = { ...routes, auth: { login: '/es/auth/login' } };

// All code uses routes.auth.login, so switching is trivial

4. Type Safety

// Compile-time checking
routes.league.detail('123'); // ✅ Works
routes.league.detail();      // ❌ Error: requires string

// Parameter validation
const path = buildPath('league.detail', { id: '123' }); // ✅
const path = buildPath('league.detail', {});            // ❌ Error

5. Maintainable

  • One file to change all routes
  • No hardcoded paths anywhere else
  • Clear separation of concerns
  • Easy to test each layer independently

Migration Checklist

Phase 1: Foundation (1 day)

  • Create RouteConfig.ts with all routes
  • Update middleware.ts to use route config
  • Remove hardcoded paths from middleware

Phase 2: Guards (2 days)

  • Create AuthGuard.tsx with route config
  • Create RoleGuard.tsx with route config
  • Remove old RouteGuard and AuthGuard files
  • Remove AuthGateway and AuthorizationBlocker

Phase 3: Route Updates (2 days)

  • Update all route layouts to use new guards
  • Remove redundant page-level checks
  • Test all redirect flows

Phase 4: API Verification (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
  • /admin → Redirects to /auth/login?returnTo=/admin
  • /leagues → Works (public)
  • /auth/login → Works (public)

Authenticated User (Regular)

  • /dashboard → Works
  • /admin → Redirects to /dashboard (no role)
  • /leagues → Works (public)
  • /auth/login → Redirects to /dashboard

Authenticated User (Admin)

  • /dashboard → Works
  • /admin → Works
  • /admin/users → Works

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 chaos by:

  1. One Source of Truth: All routes in RouteConfig.ts
  2. Clear Layers: Middleware → API → Guards → Controller
  3. No Hardcoded Paths: Everything uses the config
  4. i18n Ready: Easy to add localized routes
  5. Type Safe: Compile-time route validation
  6. Easy to Debug: Each layer has one job

Result: Clean, predictable, secure authentication that just works.