374 lines
9.7 KiB
Markdown
374 lines
9.7 KiB
Markdown
# 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**:
|
|
|
|
```typescript
|
|
// 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)
|
|
```typescript
|
|
// 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)
|
|
```typescript
|
|
// 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)
|
|
```typescript
|
|
// 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
|
|
```typescript
|
|
// app/leagues/page.tsx
|
|
export default function LeaguesPage() {
|
|
return <LeaguesList />;
|
|
}
|
|
// No protection needed
|
|
```
|
|
|
|
#### Authenticated Route
|
|
```typescript
|
|
// 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
|
|
```typescript
|
|
// 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)
|
|
```typescript
|
|
// 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
|
|
```typescript
|
|
// 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**
|
|
```bash
|
|
# 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**
|
|
```typescript
|
|
// 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**
|
|
```typescript
|
|
// 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)
|
|
- [x] Create `RouteConfig.ts` with all routes
|
|
- [x] Update `middleware.ts` to use route config
|
|
- [x] Remove hardcoded paths from middleware
|
|
|
|
### Phase 2: Guards (2 days)
|
|
- [x] Create `AuthGuard.tsx` with route config
|
|
- [x] Create `RoleGuard.tsx` with route config
|
|
- [x] Remove old `RouteGuard` and `AuthGuard` files
|
|
- [x] 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. |