640 lines
21 KiB
Markdown
640 lines
21 KiB
Markdown
# 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 (
|
|
<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`
|
|
|
|
```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 (
|
|
<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)
|
|
```typescript
|
|
// app/leagues/page.tsx
|
|
export default function LeaguesPage() {
|
|
return <LeaguesList />;
|
|
}
|
|
```
|
|
|
|
#### Authenticated Route
|
|
```typescript
|
|
// 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
|
|
```typescript
|
|
// 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)
|
|
```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 (
|
|
<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`
|
|
|
|
```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<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`
|
|
|
|
```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.** |