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
- Confusing Layers: Middleware, RouteGuards, AuthGuards, Blockers, Gateways - unclear hierarchy
- Mixed Responsibilities: Server and client both doing similar checks inconsistently
- Inconsistent Patterns: Some routes use middleware, some use guards, some use both
- Role Confusion: Frontend has role logic that should be server-only
- Debugging Nightmare: Multiple layers with unclear flow
What's Actually Working
- API Guards: Clean NestJS pattern with
@Public(),@RequireRoles() - Basic Middleware: Route protection works at edge
- Auth Context: Session management exists
- 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.tsto only check session cookie - Remove role logic from middleware
- Define clear public routes list
Phase 2: Create Clean Guards (2 days)
- Create
AuthLayoutcomponent - Create
RoleLayoutcomponent - Create
ScopedLayoutcomponent - 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:
- One Source of Truth: API server handles all security
- Clear Layers: Middleware → API → Guards → Controller
- Simple Client: UX enhancement only, no security decisions
- Predictable Flow: Always the same path for every request
- Easy to Debug: Each layer has one job
The result: Clean, predictable, secure authentication and authorization that just works.