Files
gridpilot.gg/plans/auth-finalization-plan.md
2025-12-31 19:55:43 +01:00

14 KiB

Auth Solution Finalization Plan

Overview

This plan outlines the comprehensive enhancement of the GridPilot authentication system to meet production requirements while maintaining clean architecture principles and supporting both in-memory and TypeORM implementations.

Current State Analysis

What's Working

  • Clean Architecture with proper separation of concerns
  • Email/password signup and login
  • iRacing OAuth flow (placeholder)
  • Session management with cookies
  • Basic route protection (mode-based)
  • Dev tools overlay with demo login
  • In-memory and TypeORM persistence adapters

What's Missing/Needs Enhancement

  1. Real Name Validation: Current system allows any displayName, but we need to enforce real names
  2. Modern Auth Features: No password reset, magic links, or modern recovery flows
  3. Production-Ready Demo Login: Current demo uses cookies but needs proper integration
  4. Proper Route Protection: Website middleware only checks app mode, not authentication status
  5. Enhanced Error Handling: Need better validation and user-friendly error messages
  6. Security Hardening: Need to ensure all endpoints are properly protected

Enhanced Architecture Design

1. Domain Layer Changes

User Entity Updates

// Enhanced validation for real names
export class User {
  // ... existing properties
  
  private validateDisplayName(displayName: string): void {
    const trimmed = displayName.trim();
    
    // Must be a real name (no nicknames)
    if (trimmed.length < 2) {
      throw new Error('Name must be at least 2 characters');
    }
    
    // No special characters except basic punctuation
    if (!/^[A-Za-z\s\-']{2,50}$/.test(trimmed)) {
      throw new Error('Name can only contain letters, spaces, hyphens, and apostrophes');
    }
    
    // No common nickname patterns
    const nicknamePatterns = [/^user/i, /^test/i, /^[a-z0-9_]+$/i];
    if (nicknamePatterns.some(pattern => pattern.test(trimmed))) {
      throw new Error('Please use your real name, not a nickname');
    }
    
    // Capitalize first letter of each word
    this.displayName = trimmed.replace(/\b\w/g, l => l.toUpperCase());
  }
}

New Value Objects

  • MagicLinkToken: Secure token for password reset
  • EmailVerificationToken: For email verification (future)
  • PasswordResetRequest: Entity for tracking reset requests

New Repositories

  • IMagicLinkRepository: Store and validate magic links
  • IPasswordResetRepository: Track password reset requests

2. Application Layer Changes

New Use Cases

// Forgot Password Use Case
export class ForgotPasswordUseCase {
  async execute(email: string): Promise<Result<void, ApplicationError>> {
    // 1. Validate email exists
    // 2. Generate secure token
    // 3. Store token with expiration
    // 4. Send magic link email (or return link for dev)
    // 5. Rate limiting
  }
}

// Reset Password Use Case
export class ResetPasswordUseCase {
  async execute(token: string, newPassword: string): Promise<Result<void, ApplicationError>> {
    // 1. Validate token
    // 2. Check expiration
    // 3. Update password
    // 4. Invalidate token
    // 5. Clear other sessions
  }
}

// Demo Login Use Case (Dev Only)
export class DemoLoginUseCase {
  async execute(role: 'driver' | 'sponsor'): Promise<Result<AuthSession, ApplicationError>> {
    // 1. Check environment (dev only)
    // 2. Create demo user if doesn't exist
    // 3. Generate session
    // 4. Return session
  }
}

Enhanced Signup Use Case

export class SignupUseCase {
  // Add real name validation
  // Add email format validation
  // Add password strength requirements
  // Optional: Email verification flow
}

3. API Layer Changes

New Auth Endpoints

@Public()
@Controller('auth')
export class AuthController {
  // Existing:
  // POST /auth/signup
  // POST /auth/login
  // GET /auth/session
  // POST /auth/logout
  // GET /auth/iracing/start
  // GET /auth/iracing/callback
  
  // New:
  // POST /auth/forgot-password
  // POST /auth/reset-password
  // POST /auth/demo-login (dev only)
  // POST /auth/verify-email (future)
}

Enhanced DTOs

export class SignupParamsDTO {
  @ApiProperty()
  @IsEmail()
  email: string;
  
  @ApiProperty()
  @IsString()
  @MinLength(8)
  @Matches(/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, {
    message: 'Password must contain uppercase, lowercase, and number'
  })
  password: string;
  
  @ApiProperty()
  @IsString()
  @Matches(/^[A-Za-z\s\-']{2,50}$/, {
    message: 'Please use your real name (letters, spaces, hyphens only)'
  })
  displayName: string;
}

export class ForgotPasswordDTO {
  @ApiProperty()
  @IsEmail()
  email: string;
}

export class ResetPasswordDTO {
  @ApiProperty()
  @IsString()
  token: string;
  
  @ApiProperty()
  @IsString()
  @MinLength(8)
  newPassword: string;
}

export class DemoLoginDTO {
  @ApiProperty({ enum: ['driver', 'sponsor'] })
  role: 'driver' | 'sponsor';
}

Enhanced Guards

@Injectable()
export class AuthenticationGuard implements CanActivate {
  async canActivate(context: ExecutionContext): Promise<boolean> {
    const request = context.switchToHttp().getRequest();
    
    // Check session
    const session = await this.sessionPort.getCurrentSession();
    if (!session?.user?.id) {
      throw new UnauthorizedException('Authentication required');
    }
    
    // Attach user to request
    request.user = { userId: session.user.id };
    return true;
  }
}

@Injectable()
export class ProductionGuard implements CanActivate {
  async canActivate(context: ExecutionContext): Promise<boolean> {
    // Block demo login in production
    if (process.env.NODE_ENV === 'production') {
      const request = context.switchToHttp().getRequest();
      if (request.path === '/auth/demo-login') {
        throw new ForbiddenException('Demo login not available in production');
      }
    }
    return true;
  }
}

4. Website Layer Changes

Enhanced Middleware

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;
  
  // Public routes (always accessible)
  const publicRoutes = [
    '/',
    '/auth/login',
    '/auth/signup',
    '/auth/forgot-password',
    '/auth/reset-password',
    '/auth/iracing',
    '/auth/iracing/start',
    '/auth/iracing/callback',
    '/api/auth/signup',
    '/api/auth/login',
    '/api/auth/forgot-password',
    '/api/auth/reset-password',
    '/api/auth/demo-login', // dev only
    '/api/auth/session',
    '/api/auth/logout'
  ];
  
  // Protected routes (require authentication)
  const protectedRoutes = [
    '/dashboard',
    '/profile',
    '/leagues',
    '/races',
    '/teams',
    '/sponsor',
    '/onboarding'
  ];
  
  // Check if route is public
  if (publicRoutes.includes(pathname)) {
    return NextResponse.next();
  }
  
  // Check if route is protected
  if (protectedRoutes.some(route => pathname.startsWith(route))) {
    // Verify authentication by calling API
    const response = NextResponse.next();
    
    // Add a header that can be checked by client components
    // This is a simple approach - in production, consider server-side session validation
    return response;
  }
  
  // Allow other routes
  return NextResponse.next();
}

Client-Side Route Protection

// Higher-order component for route protection
export function withAuth<P extends object>(Component: React.ComponentType<P>) {
  return function ProtectedComponent(props: P) {
    const { session, loading } = useAuth();
    const router = useRouter();
    
    useEffect(() => {
      if (!loading && !session) {
        router.push(`/auth/login?returnTo=${encodeURIComponent(window.location.pathname)}`);
      }
    }, [session, loading, router]);
    
    if (loading) {
      return <LoadingScreen />;
    }
    
    if (!session) {
      return null; // or redirecting indicator
    }
    
    return <Component {...props} />;
  };
}

// Hook for protected data fetching
export function useProtectedData<T>(fetcher: () => Promise<T>) {
  const { session, loading } = useAuth();
  const [data, setData] = useState<T | null>(null);
  const [error, setError] = useState<Error | null>(null);
  
  useEffect(() => {
    if (!loading && !session) {
      setError(new Error('Authentication required'));
      return;
    }
    
    if (session) {
      fetcher()
        .then(setData)
        .catch(setError);
    }
  }, [session, loading, fetcher]);
  
  return { data, error, loading };
}

Enhanced Auth Pages

  • Login: Add "Forgot Password" link
  • Signup: Add real name validation with helpful hints
  • New: Forgot Password page
  • New: Reset Password page
  • New: Magic Link landing page

Enhanced Dev Tools

// Add proper demo login flow
const handleDemoLogin = async (role: 'driver' | 'sponsor') => {
  try {
    const response = await fetch('/api/auth/demo-login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ role })
    });
    
    if (!response.ok) throw new Error('Demo login failed');
    
    // Refresh session
    await refreshSession();
    
    // Redirect based on role
    if (role === 'sponsor') {
      router.push('/sponsor/dashboard');
    } else {
      router.push('/dashboard');
    }
  } catch (error) {
    console.error('Demo login failed:', error);
  }
};

5. Persistence Layer Changes

Enhanced Repositories

Both InMemory and TypeORM implementations need to support:

  • Storing magic link tokens with expiration
  • Password reset request tracking
  • Rate limiting (failed login attempts)

Database Schema Updates (TypeORM)

@Entity()
export class MagicLinkToken {
  @PrimaryGeneratedColumn('uuid')
  id: string;
  
  @Column()
  userId: string;
  
  @Column()
  token: string;
  
  @Column()
  expiresAt: Date;
  
  @Column({ default: false })
  used: boolean;
  
  @CreateDateColumn()
  createdAt: Date;
}

@Entity()
export class PasswordResetRequest {
  @PrimaryGeneratedColumn('uuid')
  id: string;
  
  @Column()
  email: string;
  
  @Column()
  token: string;
  
  @Column()
  expiresAt: Date;
  
  @Column({ default: false })
  used: boolean;
  
  @Column({ default: 0 })
  attemptCount: number;
  
  @CreateDateColumn()
  createdAt: Date;
}

6. Security & Validation

Rate Limiting

  • Implement rate limiting on auth endpoints
  • Track failed login attempts
  • Lock accounts after too many failures

Input Validation

  • Email format validation
  • Password strength requirements
  • Real name validation
  • Token format validation

Environment Detection

export function isDevelopment(): boolean {
  return process.env.NODE_ENV === 'development';
}

export function isProduction(): boolean {
  return process.env.NODE_ENV === 'production';
}

export function allowDemoLogin(): boolean {
  return isDevelopment() || process.env.ALLOW_DEMO_LOGIN === 'true';
}

7. Integration Points

API Routes (Next.js)

// app/api/auth/forgot-password/route.ts
export async function POST(request: Request) {
  // Validate input
  // Call ForgotPasswordUseCase
  // Return appropriate response
}

// app/api/auth/reset-password/route.ts
export async function POST(request: Request) {
  // Validate token
  // Call ResetPasswordUseCase
  // Return success/error
}

// app/api/auth/demo-login/route.ts (dev only)
export async function POST(request: Request) {
  if (!allowDemoLogin()) {
    return NextResponse.json({ error: 'Not available' }, { status: 403 });
  }
  // Call DemoLoginUseCase
}

Website Components

// ProtectedPageWrapper.tsx
export function ProtectedPageWrapper({ children }: { children: React.ReactNode }) {
  const { session, loading } = useAuth();
  const router = useRouter();
  
  if (loading) return <LoadingScreen />;
  if (!session) {
    router.push(`/auth/login?returnTo=${encodeURIComponent(window.location.pathname)}`);
    return null;
  }
  
  return <>{children}</>;
}

// AuthForm.tsx - Reusable form with validation
// MagicLinkNotification.tsx - Show success message
// PasswordStrengthMeter.tsx - Visual feedback

Implementation Phases

Phase 1: Core Domain & Use Cases

  • Update User entity with real name validation
  • Create new use cases (ForgotPassword, ResetPassword, DemoLogin)
  • Create new repositories/interfaces
  • Add new value objects

Phase 2: API Layer

  • Add new auth endpoints
  • Create new DTOs with validation
  • Update existing endpoints with enhanced validation
  • Add guards and middleware

Phase 3: Persistence

  • Update InMemory repositories
  • Update TypeORM repositories
  • Add database migrations (if needed)
  • Implement rate limiting storage

Phase 4: Website Integration

  • Update middleware for proper route protection
  • Create new auth pages (forgot password, reset)
  • Enhance existing pages with validation
  • Update dev tools overlay
  • Add client-side route protection HOCs

Phase 5: Testing & Documentation

  • Write unit tests for new use cases
  • Write integration tests for new endpoints
  • Test both in-memory and TypeORM implementations
  • Update API documentation
  • Update architecture docs

Key Requirements Checklist

Must Work With

  • InMemory implementation
  • TypeORM implementation
  • Dev tools overlay
  • Existing session management

Must Provide

  • Demo login for dev (not production)
  • Forgot password solution (modern approach)
  • Real name validation (no nicknames)
  • Proper website route protection

Must Not Break

  • Existing signup/login flow
  • iRacing OAuth flow
  • Existing tests
  • Clean architecture principles

Success Metrics

  1. Security: All protected routes require authentication
  2. User Experience: Clear validation messages, helpful error states
  3. Developer Experience: Easy demo login, clear separation of concerns
  4. Maintainability: Clean architecture, well-tested, documented
  5. Scalability: Works with both in-memory and database persistence

Notes

  • The demo login should be clearly marked as development-only
  • Magic links should have short expiration times (15-30 minutes)
  • Consider adding email verification as a future enhancement
  • Rate limiting should be configurable per environment
  • All new features should follow the existing clean architecture patterns