9.7 KiB
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.tswith all routes - Update
middleware.tsto use route config - Remove hardcoded paths from middleware
Phase 2: Guards (2 days)
- Create
AuthGuard.tsxwith route config - Create
RoleGuard.tsxwith route config - Remove old
RouteGuardandAuthGuardfiles - Remove
AuthGatewayandAuthorizationBlocker
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:
- One Source of Truth: All routes in
RouteConfig.ts - Clear Layers: Middleware → API → Guards → Controller
- No Hardcoded Paths: Everything uses the config
- i18n Ready: Easy to add localized routes
- Type Safe: Compile-time route validation
- Easy to Debug: Each layer has one job
Result: Clean, predictable, secure authentication that just works.