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

6.2 KiB

Quick Reference: Clean Authentication & Authorization

The Golden Rules

  1. API is the source of truth - Never trust the client for security
  2. Client is UX only - Redirect, show loading, hide buttons
  3. One clear flow - Middleware → API → Guard → Controller
  4. Roles are server-side - Client only knows "can access" or "can't"

What Goes Where

Server-Side (API)

// ✅ DO: Check permissions
@RequireRoles('admin')
@Get('users')
getUsers() { ... }

// ✅ DO: Return 401/403
throw new UnauthorizedException('Auth required')
throw new ForbiddenException('No permission')

// ❌ DON'T: Redirect
res.redirect('/login') // Never do this

// ❌ DON'T: Trust client identity
const userId = req.body.userId // Wrong!
const userId = req.user.userId // Correct

Client-Side (Website)

// ✅ DO: Redirect unauthenticated users
if (!session && !loading) {
  router.push('/auth/login')
}

// ✅ DO: Show loading states
if (loading) return <Loading />

// ✅ DO: Hide UI elements
{canAccess && <button>Delete</button>}

// ❌ DON'T: Make security decisions
if (user.role === 'admin') // Wrong! API decides

// ❌ DON'T: Trust your own checks
// Client checks are UX only, API is the gatekeeper

Route Protection Patterns

Public Route

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

Authenticated Route

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

export default function DashboardLayout({ children }) {
  return <AuthLayout>{children}</AuthLayout>;
}

// app/dashboard/page.tsx
export default function DashboardPage() {
  return <DashboardContent />;
}
// Layout handles auth check, page is clean

Role-Protected Route

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

export default function AdminLayout({ children }) {
  return (
    <RoleLayout requiredRoles={['owner', 'admin']}>
      {children}
    </RoleLayout>
  );
}

// app/admin/page.tsx
export default function AdminPage() {
  return <AdminDashboard />;
}
// Layout handles role check

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 }) {
  return (
    <AuthLayout>
      <LeagueAccessGuard leagueId={params.id}>
        {children}
      </LeagueAccessGuard>
    </AuthLayout>
  );
}
// Multiple guards for complex scenarios

API Endpoint Patterns

Public Endpoint

@Public()
@Get('pricing')
getPricing() { ... }
// No auth required

Authenticated Endpoint

@RequireAuthenticatedUser()
@Get('profile')
getProfile(@User() user: UserEntity) { ... }
// Any logged-in user

Role-Protected Endpoint

@RequireRoles('admin')
@Get('users')
getUsers() { ... }
// Only admins

Scoped Endpoint

@RequireAuthenticatedUser()
@Get('leagues/:leagueId/admin')
getLeagueAdmin(
  @Param('leagueId') leagueId: string,
  @User() user: UserEntity
) {
  // Check if user is league admin
  this.leagueService.verifyLeagueAdmin(leagueId, user.id);
  ...
}
// Check scope in service

Error Handling

API Returns

  • 401 Unauthorized: No/invalid session
  • 403 Forbidden: Has session but no permission
  • 404 Not Found: Resource doesn't exist OR non-disclosure

Client Handles

try {
  const data = await apiFetch('/api/admin/users');
  return data;
} catch (error) {
  if (error.message.includes('401')) {
    // Redirect to login
    window.location.href = '/auth/login';
  } else if (error.message.includes('403')) {
    // Show access denied
    toast.error('You need admin access');
    router.push('/dashboard');
  } else {
    // Show error
    toast.error(error.message);
  }
}

Common Mistakes

Wrong

// Client making security decisions
function AdminPage() {
  const { session } = useAuth();
  if (session?.role !== 'admin') return <AccessDenied />;
  return <AdminDashboard />;
}

// API trusting client
@Post('delete')
deleteUser(@Body() body: { userId: string }) {
  const userId = body.userId; // Could be anyone!
  ...
}

// Middleware doing too much
if (user.role === 'admin') { // Wrong place for this!
  return NextResponse.next();
}

Correct

// Client handles UX only
function AdminPage() {
  return (
    <RoleLayout requiredRoles={['admin']}>
      <AdminDashboard />
    </RoleLayout>
  );
}

// API is source of truth
@Post('delete')
@RequireRoles('admin')
deleteUser(@User() user: UserEntity, @Body() body: { userId: string }) {
  // user.id is from session, body.userId is target
  // Service verifies permissions
  ...
}

// Middleware only checks auth
if (!hasAuthCookie) {
  return redirect('/login');
}
// Let API handle roles

Testing Checklist

Before Deploy

  • Unauthenticated user can't access protected routes
  • Authenticated user can access their routes
  • Wrong role gets redirected/denied
  • Session expiry redirects to login
  • API returns proper 401/403 codes
  • Public routes work without login

Quick Test Commands

# Test API directly
curl -I http://localhost:3000/api/admin/users
# Should return 401 (no auth)

# Test with session
curl -I http://localhost:3000/api/admin/users \
  -H "Cookie: gp_session=valid_token"
# Should return 200 or 403 depending on role

# Test public route
curl -I http://localhost:3000/api/leagues/all
# Should return 200

Migration Steps

  1. Simplify middleware - Remove role logic
  2. Create clean guards - AuthLayout, RoleLayout
  3. Update layouts - Replace old RouteGuard
  4. Test all routes - Check redirects work
  5. Verify API - All endpoints have proper decorators

Remember

  • Server: Security, permissions, data filtering
  • Client: UX, loading states, redirects
  • Flow: Always the same, always predictable
  • Debug: Check each layer in order

When in doubt: The API decides. The client just shows what the API says.