monacousa-portal/KEYCLOAK_CUSTOM_LOGIN_IMPLE...

40 KiB

🔐 Keycloak Custom Login Implementation Guide

📋 Table of Contents

  1. Overview & Architecture
  2. Keycloak Configuration
  3. Environment Setup
  4. Server-Side Implementation
  5. Client-Side Implementation
  6. Authentication Flow
  7. Session Management
  8. Security Implementation
  9. Middleware System
  10. Error Handling
  11. Testing & Debugging
  12. Production Considerations
  13. Troubleshooting

🏗️ Overview & Architecture

Why Custom Login Instead of Keycloak Hosted Pages

Benefits:

  • Complete UI Control: Custom branding, responsive design, mobile optimization
  • Better UX: No redirects to external domains, seamless user experience
  • Integration: Easy integration with existing Nuxt/Vue components
  • Mobile Compatibility: Full control over mobile browser behavior
  • Performance: No external redirects, faster login experience

Trade-offs:

  • ⚠️ More Complex: Must handle security, session management, token validation
  • ⚠️ Security Responsibility: Must implement proper rate limiting, CSRF protection
  • ⚠️ Maintenance: More code to maintain vs. Keycloak's hosted pages

Architecture Overview

graph TD
    A[Client Browser] --> B[Nuxt App]
    B --> C[Custom Login Page]
    C --> D[Server API Route]
    D --> E[Keycloak Token Endpoint]
    E --> F[Return Access Token]
    F --> D
    D --> G[Create Encrypted Session]
    G --> H[Set HTTP-Only Cookie]
    H --> B
    B --> I[Authenticated App]

Tech Stack

  • Frontend: Nuxt 3 + Vue 3 + Vuetify 3
  • Backend: Nuxt 3 Server Routes
  • Authentication: Keycloak (Resource Owner Password Credentials flow)
  • Session: Encrypted server-side session storage
  • Security: Rate limiting, input validation, CSRF protection

🔧 Keycloak Configuration

1. Client Configuration

In Keycloak Admin Console:

// Client Settings
{
  clientId: "monacousa-portal",
  clientType: "confidential", // Important: Must be confidential
  standardFlowEnabled: false, // Disable standard flow
  directAccessGrantsEnabled: true, // Enable direct access grants
  serviceAccountsEnabled: false,
  publicClient: false,
  
  // Valid Redirect URIs (for OAuth fallback if needed)
  redirectUris: [
    "https://yourdomain.com/auth/callback",
    "http://localhost:3000/auth/callback" // Dev only
  ],
  
  // Root URL
  rootUrl: "https://yourdomain.com",
  adminUrl: "https://yourdomain.com",
  baseUrl: "https://yourdomain.com",
  
  // Advanced Settings
  accessTokenLifespan: "15 minutes",
  ssoSessionIdleTimeout: "30 minutes",
  ssoSessionMaxLifespan: "12 hours"
}

2. Required Scopes

// Default Client Scopes (include these)
[
  "openid",     // OpenID Connect
  "profile",    // User profile information  
  "email",      // Email address
  "roles",      // User roles/groups
  "groups"      // User groups (if using)
]

3. User Groups/Roles Setup

// Example Group Structure
{
  groups: [
    {
      name: "admin",
      description: "System administrators"
    },
    {
      name: "board", 
      description: "Board members"
    },
    {
      name: "user",
      description: "Regular users"
    }
  ]
}

4. Keycloak Client Secret

# In Keycloak Admin Console:
# Clients → [Your Client] → Credentials → Client Secret
# Copy this secret for environment variables

🌐 Environment Setup

Environment Variables

# .env
# Keycloak Configuration
NUXT_KEYCLOAK_ISSUER=https://auth.yourdomain.com/realms/your-realm
NUXT_KEYCLOAK_CLIENT_ID=your-client-id
NUXT_KEYCLOAK_CLIENT_SECRET=your-client-secret
NUXT_KEYCLOAK_CALLBACK_URL=https://yourdomain.com/auth/callback

# Session Security
NUXT_SESSION_SECRET=your-48-character-session-secret-key-here-123456
NUXT_ENCRYPTION_KEY=your-32-character-encryption-key-here12

# Public Configuration
NUXT_PUBLIC_DOMAIN=yourdomain.com

Nuxt Configuration

// nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    // Private (server-side only)
    keycloak: {
      issuer: process.env.NUXT_KEYCLOAK_ISSUER,
      clientId: process.env.NUXT_KEYCLOAK_CLIENT_ID,
      clientSecret: process.env.NUXT_KEYCLOAK_CLIENT_SECRET,
      callbackUrl: process.env.NUXT_KEYCLOAK_CALLBACK_URL
    },
    sessionSecret: process.env.NUXT_SESSION_SECRET,
    encryptionKey: process.env.NUXT_ENCRYPTION_KEY,
    
    // Public (client-side accessible)
    public: {
      domain: process.env.NUXT_PUBLIC_DOMAIN
    }
  },
  
  // SSR Configuration for auth
  ssr: false, // or configure properly for SSR
  
  // Security headers
  nitro: {
    experimental: {
      wasm: true
    }
  }
})

🖥️ Server-Side Implementation

1. Direct Login API Route

// server/api/auth/direct-login.post.ts
export default defineEventHandler(async (event) => {
  try {
    const { username, password, rememberMe } = await readBody(event);
    const config = useRuntimeConfig();
    
    // Input validation
    if (!username || !password) {
      throw createError({
        statusCode: 400,
        statusMessage: 'Username and password are required'
      });
    }

    // Rate limiting
    const clientIP = getClientIP(event);
    if (!checkRateLimit(clientIP)) {
      throw createError({
        statusCode: 429,
        statusMessage: 'Too many login attempts'
      });
    }

    // Direct authentication with Keycloak
    const tokenResponse = await fetch(`${config.keycloak.issuer}/protocol/openid-connect/token`, {
      method: 'POST',
      headers: { 
        'Content-Type': 'application/x-www-form-urlencoded',
        'User-Agent': 'YourApp/1.0'
      },
      body: new URLSearchParams({
        grant_type: 'password',
        client_id: config.keycloak.clientId,
        client_secret: config.keycloak.clientSecret,
        username,
        password,
        scope: 'openid email profile roles'
      })
    });

    if (!tokenResponse.ok) {
      throw createError({
        statusCode: 401,
        statusMessage: 'Invalid credentials'
      });
    }

    const tokens = await tokenResponse.json();

    // Get user info from Keycloak
    const userResponse = await fetch(`${config.keycloak.issuer}/protocol/openid-connect/userinfo`, {
      headers: { 
        'Authorization': `Bearer ${tokens.access_token}`
      }
    });

    const userInfo = await userResponse.json();

    // Extract user groups and determine tier
    const groups = extractUserGroups(tokens, userInfo);
    const userTier = determineTier(groups);

    // Create session
    const sessionData = {
      user: {
        id: userInfo.sub,
        email: userInfo.email,
        name: userInfo.name,
        firstName: userInfo.given_name,
        lastName: userInfo.family_name,
        tier: userTier,
        groups: groups
      },
      tokens: {
        accessToken: tokens.access_token,
        refreshToken: tokens.refresh_token,
        expiresAt: Date.now() + (tokens.expires_in * 1000)
      },
      rememberMe: !!rememberMe,
      createdAt: Date.now(),
      lastActivity: Date.now()
    };

    // Create encrypted session cookie
    const sessionManager = createSessionManager();
    const cookieString = sessionManager.createSession(sessionData, !!rememberMe);
    
    // Set secure cookie
    const maxAge = !!rememberMe ? 60 * 60 * 24 * 30 : 60 * 60 * 24 * 7;
    const isProduction = process.env.NODE_ENV === 'production';
    
    setCookie(event, 'your-session-name', cookieString.split('=')[1], {
      httpOnly: true,
      secure: isProduction,
      sameSite: 'lax',
      maxAge,
      path: '/'
    });

    return {
      success: true,
      redirectTo: '/dashboard',
      user: {
        email: userInfo.email,
        name: userInfo.name,
        tier: userTier
      }
    };

  } catch (error) {
    // Error handling
    throw createError({
      statusCode: error.statusCode || 500,
      statusMessage: error.statusMessage || 'Authentication failed'
    });
  }
});

2. Session Validation API

// server/api/auth/session.get.ts
export default defineEventHandler(async (event) => {
  try {
    const sessionManager = createSessionManager();
    const sessionData = sessionManager.getSession(event);
    
    if (!sessionData || !sessionData.user) {
      return {
        authenticated: false,
        user: null
      };
    }
    
    // Check if token needs refresh
    if (sessionData.tokens.expiresAt < Date.now() + 60000) { // 1 minute buffer
      try {
        const refreshedTokens = await refreshKeycloakToken(sessionData.tokens.refreshToken);
        
        // Update session with new tokens
        sessionData.tokens = {
          accessToken: refreshedTokens.access_token,
          refreshToken: refreshedTokens.refresh_token,
          expiresAt: Date.now() + (refreshedTokens.expires_in * 1000)
        };
        
        sessionManager.updateSession(event, sessionData);
      } catch (refreshError) {
        console.warn('Token refresh failed:', refreshError);
        // Session is invalid, force logout
        return {
          authenticated: false,
          user: null
        };
      }
    }
    
    // Update last activity
    sessionData.lastActivity = Date.now();
    sessionManager.updateSession(event, sessionData);
    
    return {
      authenticated: true,
      user: sessionData.user
    };
    
  } catch (error) {
    console.error('Session validation error:', error);
    return {
      authenticated: false,
      user: null
    };
  }
});

3. Session Management Utilities

// server/utils/session.ts
import crypto from 'crypto';

interface SessionData {
  user: User;
  tokens: {
    accessToken: string;
    refreshToken: string;
    expiresAt: number;
  };
  rememberMe: boolean;
  createdAt: number;
  lastActivity: number;
}

export const createSessionManager = () => {
  const config = useRuntimeConfig();
  
  const encryptData = (data: any): string => {
    const cipher = crypto.createCipher('aes-256-cbc', config.encryptionKey);
    let encrypted = cipher.update(JSON.stringify(data), 'utf8', 'hex');
    encrypted += cipher.final('hex');
    return encrypted;
  };

  const decryptData = (encryptedData: string): any => {
    try {
      const decipher = crypto.createDecipher('aes-256-cbc', config.encryptionKey);
      let decrypted = decipher.update(encryptedData, 'hex', 'utf8');
      decrypted += decipher.final('utf8');
      return JSON.parse(decrypted);
    } catch (error) {
      return null;
    }
  };

  const createSession = (sessionData: SessionData, rememberMe: boolean): string => {
    const sessionId = crypto.randomUUID();
    const encryptedData = encryptData(sessionData);
    
    // Store in server-side storage (memory, database, Redis, etc.)
    serverSessionStorage.set(sessionId, encryptedData);
    
    return `session-id=${sessionId}`;
  };

  const getSession = (event: any): SessionData | null => {
    const sessionId = getCookie(event, 'your-session-name');
    if (!sessionId) return null;
    
    const encryptedData = serverSessionStorage.get(sessionId);
    if (!encryptedData) return null;
    
    return decryptData(encryptedData);
  };

  return {
    createSession,
    getSession,
    updateSession: (event: any, sessionData: SessionData) => {
      const sessionId = getCookie(event, 'your-session-name');
      if (sessionId) {
        const encryptedData = encryptData(sessionData);
        serverSessionStorage.set(sessionId, encryptedData);
      }
    },
    destroySession: (event: any) => {
      const sessionId = getCookie(event, 'your-session-name');
      if (sessionId) {
        serverSessionStorage.delete(sessionId);
        deleteCookie(event, 'your-session-name');
      }
    }
  };
};

🎨 Client-Side Implementation

1. Authentication Composable

// composables/useAuth.ts
export const useAuth = () => {
  // Use useState for SSR compatibility
  const user = useState<User | null>('auth.user', () => null);
  const isAuthenticated = computed(() => !!user.value);
  const loading = ref(false);
  const error = ref<string | null>(null);

  // Tier-based computed properties
  const userTier = computed(() => user.value?.tier || 'user');
  const isUser = computed(() => user.value?.tier === 'user');
  const isBoard = computed(() => user.value?.tier === 'board');
  const isAdmin = computed(() => user.value?.tier === 'admin');

  // Login method
  const login = async (credentials: LoginCredentials) => {
    loading.value = true;
    error.value = null;
    
    try {
      const response = await $fetch<LoginResponse>('/api/auth/direct-login', {
        method: 'POST',
        body: credentials,
        timeout: 30000
      });
      
      if (response.success) {
        // Wait for cookie to be set
        await new Promise(resolve => setTimeout(resolve, 200));
        
        // Verify session was created properly
        let sessionSuccess = false;
        let attempts = 0;
        const maxAttempts = 3;
        
        while (!sessionSuccess && attempts < maxAttempts) {
          attempts++;
          sessionSuccess = await checkAuth();
          
          if (!sessionSuccess && attempts < maxAttempts) {
            await new Promise(resolve => setTimeout(resolve, 500));
          }
        }
        
        if (sessionSuccess) {
          return { 
            success: true, 
            redirectTo: response.redirectTo || '/dashboard' 
          };
        }
      }
      
      return { success: false, error: 'Login failed' };
    } catch (err: any) {
      const errorMessage = getErrorMessage(err);
      error.value = errorMessage;
      return { success: false, error: errorMessage };
    } finally {
      loading.value = false;
    }
  };

  // Check authentication status
  const checkAuth = async (): Promise<boolean> => {
    try {
      const response = await $fetch<SessionResponse>('/api/auth/session');
      
      if (response.authenticated && response.user) {
        user.value = response.user;
        return true;
      } else {
        user.value = null;
        return false;
      }
    } catch (err) {
      user.value = null;
      return false;
    }
  };

  // Logout method
  const logout = async () => {
    try {
      await $fetch('/api/auth/logout', { method: 'POST' });
      user.value = null;
      await navigateTo('/login');
    } catch (err) {
      user.value = null;
      await navigateTo('/login');
    }
  };

  return {
    user: readonly(user),
    isAuthenticated,
    loading: readonly(loading),
    error: readonly(error),
    userTier,
    isUser,
    isBoard,
    isAdmin,
    login,
    logout,
    checkAuth
  };
};

2. Custom Login Page

<!-- pages/login.vue -->
<template>
  <div class="login-container">
    <v-container fluid class="fill-height">
      <v-row class="fill-height" justify="center" align="center">
        <v-col cols="12" sm="8" md="6" lg="4" xl="3">
          <v-card class="login-card" elevation="24">
            <v-card-text class="pa-8">
              <!-- Logo and Branding -->
              <div class="text-center mb-8">
                <v-img 
                  src="/logo.png" 
                  width="120" 
                  height="120"
                  class="mx-auto mb-4" 
                  alt="Company Logo"
                />
                <h1 class="text-h4 font-weight-bold mb-2">
                  Welcome Back
                </h1>
                <p class="text-body-1 text-medium-emphasis">
                  Sign in to your portal
                </p>
              </div>

              <!-- Login Form -->
              <v-form @submit.prevent="handleLogin" ref="loginForm">
                <v-text-field
                  v-model="credentials.username"
                  label="Username or Email"
                  prepend-inner-icon="mdi-account"
                  variant="outlined"
                  :error-messages="errors.username"
                  :disabled="loading"
                  class="mb-3"
                  required
                />
                
                <v-text-field
                  v-model="credentials.password"
                  :type="showPassword ? 'text' : 'password'"
                  label="Password"
                  prepend-inner-icon="mdi-lock"
                  :append-inner-icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'"
                  @click:append-inner="showPassword = !showPassword"
                  variant="outlined"
                  :error-messages="errors.password"
                  :disabled="loading"
                  class="mb-3"
                  required
                />

                <div class="d-flex justify-space-between align-center mb-6">
                  <v-checkbox 
                    v-model="credentials.rememberMe" 
                    label="Remember me"
                    density="compact"
                    :disabled="loading"
                  />
                </div>

                <!-- Error Alert -->
                <v-alert
                  v-if="loginError"
                  type="error"
                  variant="tonal"
                  class="mb-4"
                  closable
                  @click:close="loginError = ''"
                >
                  {{ loginError }}
                </v-alert>

                <!-- Login Button -->
                <v-btn
                  type="submit"
                  color="primary"
                  size="large"
                  block
                  :loading="loading"
                  :disabled="!isFormValid"
                  class="mb-4"
                >
                  <v-icon left>mdi-login</v-icon>
                  Sign In
                </v-btn>
              </v-form>
            </v-card-text>
          </v-card>
        </v-col>
      </v-row>
    </v-container>
  </div>
</template>

<script setup lang="ts">
definePageMeta({
  layout: false,
  middleware: 'guest'  // Prevents authenticated users from accessing login
});

const { login, loading: authLoading, error: authError } = useAuth();

// Form state
const credentials = ref({
  username: '',
  password: '',
  rememberMe: false
});

const showPassword = ref(false);
const loginError = ref('');
const errors = ref({
  username: '',
  password: ''
});

const loading = computed(() => authLoading.value);
const isFormValid = computed(() => {
  return credentials.value.username.length > 0 && 
         credentials.value.password.length > 0 && 
         !loading.value;
});

// Form validation
const validateForm = () => {
  errors.value = { username: '', password: '' };
  let isValid = true;

  if (!credentials.value.username || credentials.value.username.length < 2) {
    errors.value.username = 'Username must be at least 2 characters';
    isValid = false;
  }

  if (!credentials.value.password || credentials.value.password.length < 6) {
    errors.value.password = 'Password must be at least 6 characters';
    isValid = false;
  }

  return isValid;
};

// Handle login submission
const handleLogin = async () => {
  if (!validateForm()) return;

  loginError.value = '';

  try {
    const result = await login({
      username: credentials.value.username,
      password: credentials.value.password,
      rememberMe: credentials.value.rememberMe
    });

    if (result.success) {
      await navigateTo(result.redirectTo || '/dashboard');
    } else {
      loginError.value = result.error || 'Login failed. Please check your credentials.';
    }
  } catch (error: any) {
    loginError.value = authError.value || 'Login failed. Please try again.';
  }
};

// Auto-focus username field
onMounted(() => {
  nextTick(() => {
    const usernameField = document.querySelector('input[type="text"]') as HTMLInputElement;
    if (usernameField) {
      usernameField.focus();
    }
  });
});
</script>

3. Auth Plugin (Initialize Auth State)

// plugins/01.auth-check.client.ts
export default defineNuxtPlugin(async () => {
  const { checkAuth } = useAuth();
  
  // Check authentication status on app startup
  await checkAuth();
});

🛡️ Security Implementation

1. Rate Limiting

// server/utils/security.ts
const loginAttempts = new Map<string, { count: number; lastAttempt: number }>();
const blockedIPs = new Map<string, number>();

export const checkRateLimit = (ip: string): { allowed: boolean; attemptsLeft: number } => {
  const now = Date.now();
  const maxAttempts = 5;
  const windowMs = 15 * 60 * 1000; // 15 minutes
  const blockDurationMs = 60 * 60 * 1000; // 1 hour

  // Check if IP is blocked
  const blockedUntil = blockedIPs.get(ip);
  if (blockedUntil && now < blockedUntil) {
    return { allowed: false, attemptsLeft: 0 };
  }

  // Clear expired blocks
  if (blockedUntil && now >= blockedUntil) {
    blockedIPs.delete(ip);
  }

  // Check current attempts
  const attempts = loginAttempts.get(ip);
  if (attempts && (now - attempts.lastAttempt) > windowMs) {
    loginAttempts.delete(ip);
    return { allowed: true, attemptsLeft: maxAttempts };
  }

  const currentCount = attempts?.count || 0;
  if (currentCount >= maxAttempts) {
    blockedIPs.set(ip, now + blockDurationMs);
    return { allowed: false, attemptsLeft: 0 };
  }

  return { allowed: true, attemptsLeft: maxAttempts - currentCount };
};

export const recordFailedAttempt = (ip: string): void => {
  const now = Date.now();
  const attempts = loginAttempts.get(ip);
  
  if (attempts) {
    attempts.count += 1;
    attempts.lastAttempt = now;
  } else {
    loginAttempts.set(ip, { count: 1, lastAttempt: now });
  }
};

2. Input Validation

// server/utils/validation.ts
export const validateLoginInput = (username: string, password: string): string[] => {
  const errors: string[] = [];
  
  // Username validation
  if (!username || typeof username !== 'string') {
    errors.push('Username is required');
  } else if (username.length < 2) {
    errors.push('Username must be at least 2 characters');
  } else if (username.length > 100) {
    errors.push('Username is too long');
  } else if (!/^[a-zA-Z0-9@._-]+$/.test(username)) {
    errors.push('Username contains invalid characters');
  }
  
  // Password validation
  if (!password || typeof password !== 'string') {
    errors.push('Password is required');
  } else if (password.length < 6) {
    errors.push('Password must be at least 6 characters');
  } else if (password.length > 200) {
    errors.push('Password is too long');
  }
  
  return errors;
};

export const getClientIP = (event: any): string => {
  const headers = getHeaders(event);
  return (
    headers['x-forwarded-for']?.split(',')[0]?.trim() ||
    headers['x-real-ip'] ||
    headers['x-client-ip'] ||
    headers['cf-connecting-ip'] ||
    'unknown'
  );
};

3. Session Security

// Secure cookie configuration
setCookie(event, 'session-name', sessionId, {
  httpOnly: true,           // Prevent XSS attacks
  secure: isProduction,     // HTTPS only in production
  sameSite: 'lax',         // CSRF protection
  maxAge: rememberMe ? 2592000 : 604800, // 30 days vs 7 days
  path: '/',               // Available site-wide
  domain: process.env.NUXT_PUBLIC_DOMAIN ? `.${process.env.NUXT_PUBLIC_DOMAIN}` : undefined
});

🔄 Authentication Flow

Complete Authentication Flow Diagram

sequenceDiagram
    participant U as User
    participant C as Client App
    participant S as Server API
    participant K as Keycloak
    participant D as Database

    U->>C: Navigate to /login
    C->>C: Guest middleware (allow if not authenticated)
    C->>U: Show login form

    U->>C: Submit credentials
    C->>S: POST /api/auth/direct-login
    S->>S: Validate input & rate limiting
    S->>K: POST /token (ROPC flow)
    K->>S: Return access token + refresh token
    S->>K: GET /userinfo (with access token)
    K->>S: Return user profile
    S->>S: Extract groups/roles, determine tier
    S->>D: Create encrypted session
    S->>C: Set secure cookie + return success
    C->>C: Navigate to /dashboard
    C->>S: GET /api/auth/session (via cookie)
    S->>S: Decrypt session, validate tokens
    S->>C: Return user data
    C->>C: Auth middleware passes
    C->>U: Show authenticated dashboard

Session Lifecycle

  1. Login: User provides credentials → Server validates with Keycloak → Creates encrypted session
  2. Session Check: Client calls /api/auth/session → Server validates session → Returns user data
  3. Token Refresh: Server automatically refreshes tokens before expiry
  4. Logout: Client calls /api/auth/logout → Server destroys session → Redirects to login

🛠️ Middleware System

1. Auth Middleware (Protect Routes)

// middleware/auth.ts
export default defineNuxtRouteMiddleware(async (to) => {
  if (to.meta.auth === false) {
    return; // Skip auth for public pages
  }

  const { isAuthenticated, checkAuth, user } = useAuth();
  
  // Ensure auth is checked if user isn't loaded
  if (!user.value) {
    await checkAuth();
  }

  if (!isAuthenticated.value) {
    return navigateTo('/login');
  }
});

2. Guest Middleware (Redirect Authenticated Users)

// middleware/guest.ts
export default defineNuxtRouteMiddleware((to, from) => {
  const { user } = useAuth();
  
  // If user is already authenticated, redirect to dashboard
  if (user.value) {
    return navigateTo('/dashboard');
  }
});

3. Role-Based Middleware

// middleware/auth-admin.ts
export default defineNuxtRouteMiddleware((to, from) => {
  const { isAuthenticated, isAdmin } = useAuth();
  
  if (!isAuthenticated.value) {
    return navigateTo('/login');
  }
  
  if (!isAdmin.value) {
    throw createError({
      statusCode: 403,
      statusMessage: 'Access denied. Administrator privileges required.'
    });
  }
});

🧪 Testing & Debugging

1. Testing Checklist

Backend Testing:

# Test direct login endpoint
curl -X POST http://localhost:3000/api/auth/direct-login \
  -H "Content-Type: application/json" \
  -d '{"username":"test@example.com","password":"password123"}'

# Test session endpoint with cookie
curl -X GET http://localhost:3000/api/auth/session \
  -H "Cookie: your-session-name=session-id-here"

# Test logout endpoint
curl -X POST http://localhost:3000/api/auth/logout \
  -H "Cookie: your-session-name=session-id-here"

Frontend Testing:

  • Login Form Validation: Test empty fields, short passwords, invalid characters
  • Login Flow: Valid credentials → successful login → redirect to dashboard
  • Error Handling: Invalid credentials → proper error messages
  • Session Management: Page refresh → user stays logged in
  • Mobile Compatibility: Test on iOS Safari, Android Chrome
  • Remember Me: Long-term sessions work correctly

Security Testing:

  • Rate Limiting: 5+ failed attempts → IP blocked for 1 hour
  • CSRF Protection: Direct API calls without cookies → rejected
  • XSS Protection: Malicious input → properly sanitized
  • Session Security: HttpOnly cookies → not accessible via JavaScript

2. Debug Tools

Server-Side Debugging:

// Add debug logging in server routes
console.log('🔄 Login attempt:', {
  username: credentials.username,
  ip: getClientIP(event),
  timestamp: new Date().toISOString()
});

console.log('✅ Keycloak response:', {
  status: tokenResponse.status,
  hasTokens: !!tokens.access_token,
  userEmail: userInfo.email,
  groups: extractedGroups
});

Client-Side Debugging:

// Add debug logging in composables
const checkAuth = async (): Promise<boolean> => {
  console.log('🔄 Checking authentication...');
  
  try {
    const response = await $fetch<SessionResponse>('/api/auth/session');
    
    console.log('🔍 Session response:', {
      authenticated: response.authenticated,
      user: response.user?.email,
      tier: response.user?.tier
    });
    
    // ... rest of method
  } catch (err) {
    console.error('❌ Auth check failed:', err);
    // ... error handling
  }
};

Browser DevTools Inspection:

  • Network Tab: Monitor API calls to /api/auth/*
  • Application Tab: Check cookies are set correctly
  • Console Tab: Review authentication logs
  • Sources Tab: Set breakpoints in auth flow

🚨 Error Handling

1. Server-Side Error Handling

// server/api/auth/direct-login.post.ts
export default defineEventHandler(async (event) => {
  try {
    // ... authentication logic
    
  } catch (error: any) {
    console.error('❌ Login error:', {
      message: error.message,
      status: error.status,
      ip: getClientIP(event),
      timestamp: new Date().toISOString()
    });

    // Map specific errors to user-friendly messages
    if (error.status === 401) {
      recordFailedAttempt(getClientIP(event));
      
      throw createError({
        statusCode: 401,
        statusMessage: 'Invalid username or password'
      });
    }
    
    if (error.status === 429) {
      throw createError({
        statusCode: 429,
        statusMessage: 'Too many login attempts. Please try again later.'
      });
    }
    
    if (error.code === 'ECONNREFUSED') {
      throw createError({
        statusCode: 503,
        statusMessage: 'Authentication service temporarily unavailable'
      });
    }
    
    // Generic error for unexpected issues
    throw createError({
      statusCode: 500,
      statusMessage: 'Login failed. Please try again.'
    });
  }
});

2. Client-Side Error Handling

// composables/useAuth.ts
const getErrorMessage = (err: any): string => {
  // Handle network errors
  if (!err.response) {
    return 'Network error. Please check your connection.';
  }
  
  // Handle HTTP errors
  switch (err.status) {
    case 400:
      return err.data?.message || 'Invalid request. Please check your input.';
    case 401:
      return 'Invalid username or password.';
    case 403:
      return 'Access denied. Please contact your administrator.';
    case 429:
      return 'Too many login attempts. Please try again later.';
    case 502:
    case 503:
      return 'Service temporarily unavailable. Please try again.';
    case 504:
      return 'Request timeout. Please try again.';
    default:
      return err.data?.message || 'Login failed. Please try again.';
  }
};

3. User-Friendly Error Display

<!-- Login page error handling -->
<v-alert
  v-if="loginError"
  type="error"
  variant="tonal"
  class="mb-4"
  closable
  @click:close="loginError = ''"
>
  <template v-slot:prepend>
    <v-icon>mdi-alert-circle</v-icon>
  </template>
  <div>
    <strong>Login Failed</strong>
    <br>
    {{ loginError }}
  </div>
  <template v-slot:append v-if="showRetryButton">
    <v-btn
      variant="text"
      size="small"
      @click="retryLogin"
    >
      Retry
    </v-btn>
  </template>
</v-alert>

🚀 Production Considerations

1. Environment Configuration

# Production .env
NODE_ENV=production

# Use strong secrets in production
NUXT_SESSION_SECRET=generate-a-very-strong-48-character-secret-key-here
NUXT_ENCRYPTION_KEY=generate-strong-32-char-key-here

# Keycloak production URLs
NUXT_KEYCLOAK_ISSUER=https://auth.yourdomain.com/realms/your-realm
NUXT_KEYCLOAK_CLIENT_ID=your-production-client
NUXT_KEYCLOAK_CLIENT_SECRET=your-production-secret

# Security settings
NUXT_PUBLIC_DOMAIN=yourdomain.com

2. Security Headers

// nuxt.config.ts
export default defineNuxtConfig({
  nitro: {
    routeRules: {
      '/**': {
        headers: {
          // Security headers
          'X-Frame-Options': 'DENY',
          'X-Content-Type-Options': 'nosniff',
          'X-XSS-Protection': '1; mode=block',
          'Referrer-Policy': 'strict-origin-when-cross-origin',
          'Permissions-Policy': 'camera=(), microphone=(), geolocation=()',
          
          // HTTPS enforcement
          'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
          
          // Content Security Policy
          'Content-Security-Policy': [
            "default-src 'self'",
            "script-src 'self' 'unsafe-inline'",
            "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
            "font-src 'self' https://fonts.gstatic.com",
            "img-src 'self' data: https:",
            "connect-src 'self'"
          ].join('; ')
        }
      }
    }
  }
});

3. Session Storage Options

In-Memory (Development Only):

// Simple Map - loses data on restart
const sessions = new Map<string, string>();

Redis (Recommended for Production):

// server/utils/session-storage.ts
import Redis from 'ioredis';

const redis = new Redis({
  host: process.env.REDIS_HOST || 'localhost',
  port: parseInt(process.env.REDIS_PORT || '6379'),
  password: process.env.REDIS_PASSWORD,
  db: 0,
  retryDelayOnFailover: 100,
  maxRetriesPerRequest: 3
});

export const sessionStorage = {
  async set(key: string, value: string, ttl: number = 3600): Promise<void> {
    await redis.setex(`session:${key}`, ttl, value);
  },
  
  async get(key: string): Promise<string | null> {
    return await redis.get(`session:${key}`);
  },
  
  async delete(key: string): Promise<void> {
    await redis.del(`session:${key}`);
  }
};

Database (Alternative):

-- Session table schema
CREATE TABLE user_sessions (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  session_id VARCHAR(255) UNIQUE NOT NULL,
  user_id VARCHAR(255) NOT NULL,
  session_data TEXT NOT NULL,
  expires_at TIMESTAMP NOT NULL,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  last_activity TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_sessions_session_id ON user_sessions(session_id);
CREATE INDEX idx_sessions_expires_at ON user_sessions(expires_at);

4. Monitoring & Logging

// server/utils/monitoring.ts
export const logAuthEvent = (event: string, data: any) => {
  const logData = {
    timestamp: new Date().toISOString(),
    event,
    ip: data.ip || 'unknown',
    userAgent: data.userAgent || 'unknown',
    user: data.user || 'anonymous',
    success: data.success || false,
    error: data.error || null
  };
  
  // Send to monitoring service (e.g., DataDog, New Relic, etc.)
  console.log(JSON.stringify(logData));
  
  // In production, send to your logging service:
  // await sendToLoggingService(logData);
};

// Usage in auth routes
logAuthEvent('login_attempt', {
  ip: getClientIP(event),
  user: username,
  success: true
});

5. Performance Optimization

// Cache Keycloak public key for token validation
let keycloakPublicKey: string | null = null;
let keyLastFetched = 0;
const KEY_CACHE_DURATION = 3600000; // 1 hour

const getKeycloakPublicKey = async (): Promise<string> => {
  const now = Date.now();
  
  if (!keycloakPublicKey || (now - keyLastFetched) > KEY_CACHE_DURATION) {
    const config = useRuntimeConfig();
    const response = await fetch(`${config.keycloak.issuer}/.well-known/openid_configuration`);
    const oidcConfig = await response.json();
    
    const keysResponse = await fetch(oidcConfig.jwks_uri);
    const keys = await keysResponse.json();
    
    // Extract public key from JWKS
    keycloakPublicKey = keys.keys[0].x5c[0];
    keyLastFetched = now;
  }
  
  return keycloakPublicKey!;
};

🔧 Troubleshooting

1. Common Issues

Issue: "Invalid client credentials"

# Solution: Check Keycloak client configuration
- Verify client ID matches environment variable
- Ensure client secret is correct
- Confirm client type is "confidential"
- Check "Direct Access Grants" is enabled

Issue: "CORS errors during login"

# Solution: Configure Keycloak CORS settings
- In Keycloak Admin Console
- Go to Client → Settings → Advanced Settings
- Add your domain to "Web Origins"
- Set "Valid Redirect URIs" properly

Issue: "Session not persisting after login"

// Solution: Check cookie configuration
setCookie(event, 'session-name', sessionId, {
  httpOnly: true,
  secure: process.env.NODE_ENV === 'production', // Important!
  sameSite: 'lax',
  domain: process.env.NODE_ENV === 'production' ? '.yourdomain.com' : undefined
});

Issue: "Redirect loop after login"

// Solution: Check middleware configuration
// Ensure pages have correct middleware:
- Login page: middleware: 'guest'
- Dashboard pages: middleware: 'auth'
- Index page: client-side navigation only

Issue: "User groups/roles not being extracted"

// Solution: Configure group mappers in Keycloak
// 1. Go to Client Scopes → roles → Mappers
// 2. Create mapper: "groups"
// 3. Mapper Type: "Group Membership"
// 4. Token Claim Name: "groups"
// 5. Add to access token and ID token

2. Debug Commands

# Check Keycloak connectivity
curl -X GET "https://auth.yourdomain.com/realms/your-realm/.well-known/openid_configuration"

# Test token endpoint directly
curl -X POST "https://auth.yourdomain.com/realms/your-realm/protocol/openid-connect/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=password&client_id=your-client&client_secret=your-secret&username=test&password=test123"

# Verify user info endpoint
curl -X GET "https://auth.yourdomain.com/realms/your-realm/protocol/openid-connect/userinfo" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"

3. Common Configuration Mistakes

Wrong Grant Type:

// WRONG - Don't use authorization code flow for custom login
standardFlowEnabled: true
directAccessGrantsEnabled: false

Correct Grant Type:

// CORRECT - Use Resource Owner Password Credentials
standardFlowEnabled: false
directAccessGrantsEnabled: true

Wrong Cookie Settings:

// WRONG - Insecure cookie settings
setCookie(event, 'session', sessionId, {
  httpOnly: false,  // ❌ XSS vulnerable
  secure: false,    // ❌ Not secure over HTTP
  sameSite: 'none'  // ❌ CSRF vulnerable
});

Correct Cookie Settings:

// CORRECT - Secure cookie settings
setCookie(event, 'session', sessionId, {
  httpOnly: true,           // ✅ XSS protection
  secure: isProduction,     // ✅ HTTPS in production
  sameSite: 'lax'          // ✅ CSRF protection
});

4. Performance Troubleshooting

Slow Login Response:

  • Check network latency to Keycloak server
  • Verify Keycloak server performance
  • Consider caching user info for short periods
  • Optimize session creation process

High Memory Usage:

  • Implement session cleanup for expired sessions
  • Use external session storage (Redis) instead of memory
  • Set appropriate session TTL values

Database Connection Issues:

  • Configure proper connection pooling
  • Set appropriate timeout values
  • Implement retry logic for transient failures

📚 Additional Resources

Keycloak Documentation

Security Best Practices

Nuxt 3 Specific


🎉 Conclusion

This comprehensive guide covers implementing a custom login page with Keycloak in a Nuxt 3 application. The solution provides:

Complete UI Control: Custom branded login experience Mobile Compatibility: Works perfectly on all devices including iOS Safari Enterprise Security: Rate limiting, input validation, secure sessions Production Ready: Proper error handling, monitoring, and scalability Maintainable: Clean separation of concerns and well-documented code

The implementation balances security, performance, and user experience while providing a solid foundation for enterprise authentication needs.