40 KiB
🔐 Keycloak Custom Login Implementation Guide
📋 Table of Contents
- Overview & Architecture
- Keycloak Configuration
- Environment Setup
- Server-Side Implementation
- Client-Side Implementation
- Authentication Flow
- Session Management
- Security Implementation
- Middleware System
- Error Handling
- Testing & Debugging
- Production Considerations
- 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
- Login: User provides credentials → Server validates with Keycloak → Creates encrypted session
- Session Check: Client calls
/api/auth/session→ Server validates session → Returns user data - Token Refresh: Server automatically refreshes tokens before expiry
- 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.