diff --git a/docs/authentication-session-timeout-fix-deployment.md b/docs/authentication-session-timeout-fix-deployment.md new file mode 100644 index 0000000..6338e02 --- /dev/null +++ b/docs/authentication-session-timeout-fix-deployment.md @@ -0,0 +1,310 @@ +# Authentication Session Timeout Fix - Deployment Guide + +## Overview + +This document provides step-by-step instructions for deploying the authentication session timeout fixes that resolve the 2-minute logout issue. + +## Problem Summary + +Users were experiencing unexpected logouts after exactly 2 minutes when navigating between pages. This was caused by: + +1. **Timing Race Condition**: Authentication middleware cache expiry (2 minutes) and auth refresh plugin periodic validation (2 minutes) occurring simultaneously +2. **No Request Deduplication**: Multiple concurrent session checks causing conflicts +3. **Insufficient Error Handling**: Network errors triggering immediate logouts +4. **No Grace Periods**: Transient issues causing permanent session loss + +## Solution Overview + +### Core Changes + +1. **Session Manager Utility** (`server/utils/session-manager.ts`) + - Centralized session management with request deduplication + - Promise caching for in-flight requests + - Network error grace periods + - Comprehensive logging and statistics + +2. **Authentication Middleware** (`middleware/authentication.ts`) + - Changed cache expiry from 2 to 3 minutes with jitter + - Integrated SessionManager for deduplication + - Enhanced error handling and user feedback + +3. **Auth Refresh Plugin** (`plugins/01.auth-refresh.client.ts`) + - Added random offset to prevent simultaneous validation + - Improved concurrent validation prevention + - Better error handling for network issues + +4. **Session API** (`server/api/auth/session.ts`) + - Enhanced logging with request IDs + - Detailed error categorization + - Performance timing measurements + +5. **Keycloak Client** (`server/utils/keycloak-client.ts`) + - Better error type distinction + - Increased retry attempts for token refresh + - Improved timeout handling + +6. **Refresh API** (`server/api/auth/refresh.ts`) + - Enhanced error handling with request IDs + - Grace period support for transient failures + - Selective session clearing based on error type + +## Pre-deployment Checklist + +- [ ] **Code Review**: All changes reviewed and approved +- [ ] **Environment Variables**: Verify all required environment variables are set +- [ ] **Dependencies**: Confirm no new dependencies are required +- [ ] **Backup**: Create backup of current production code +- [ ] **Monitoring**: Ensure authentication logs are being captured +- [ ] **Testing**: Verify fixes work in staging environment (if available) + +## Deployment Steps + +### Step 1: Deploy Session Manager Utility + +1. Deploy `server/utils/session-manager.ts` +2. Verify no TypeScript compilation errors +3. Check server logs for any startup issues + +### Step 2: Update Authentication Middleware + +1. Deploy updated `middleware/authentication.ts` +2. Monitor for any middleware errors in logs +3. Verify new timing configuration is active + +### Step 3: Update Auth Refresh Plugin + +1. Deploy updated `plugins/01.auth-refresh.client.ts` +2. Check browser console for any client-side errors +3. Verify random offset is working (check logs) + +### Step 4: Update Session API + +1. Deploy updated `server/api/auth/session.ts` +2. Monitor API endpoint logs for request IDs +3. Verify enhanced error messages are working + +### Step 5: Update Keycloak Client + +1. Deploy updated `server/utils/keycloak-client.ts` +2. Check for any Keycloak communication errors +3. Verify retry logic is functioning + +### Step 6: Update Refresh API + +1. Deploy updated `server/api/auth/refresh.ts` +2. Monitor token refresh operations +3. Verify graceful error handling + +## Post-deployment Verification + +### Immediate Verification (0-5 minutes) + +1. **No Deployment Errors** + ```bash + # Check server logs + tail -f /var/log/application.log | grep -E "(ERROR|FATAL)" + + # Check for any 500 errors + curl -I https://your-domain.com/api/health + ``` + +2. **Login Flow Test** + - Navigate to login page + - Complete authentication + - Verify successful redirect to dashboard + +3. **Session API Test** + ```bash + # Test session endpoint + curl -X GET https://your-domain.com/api/auth/session \ + -H "Cookie: nuxt-oidc-auth=" + ``` + +### Short-term Verification (5-15 minutes) + +1. **Navigation Test** + - Stay logged in for 5+ minutes + - Navigate between different pages + - Verify no unexpected logouts + +2. **Log Analysis** + ```bash + # Check for new session manager logs + grep "SESSION_MANAGER" /var/log/application.log + + # Verify timing desynchronization + grep "Using cached session" /var/log/application.log + ``` + +### Long-term Verification (15+ minutes) + +1. **2-Minute Boundary Test** + - Stay logged in for exactly 2 minutes + - Navigate to a new page + - Verify user remains authenticated + +2. **3-Minute Cache Test** + - Stay on same page for 3+ minutes + - Navigate to new page + - Verify session is refreshed, not lost + +3. **Network Error Simulation** + - Temporarily block network access + - Verify graceful degradation + - Restore network and verify recovery + +## Monitoring and Alerts + +### Key Metrics to Monitor + +1. **Authentication Errors** + ```bash + # Monitor auth failure rate + grep -c "AUTH_ERROR" /var/log/application.log + ``` + +2. **Session Manager Performance** + ```bash + # Check session check durations + grep "Session check completed" /var/log/application.log + ``` + +3. **Cache Hit Rate** + ```bash + # Monitor cache effectiveness + grep "Using cached session" /var/log/application.log | wc -l + ``` + +### Alert Thresholds + +- **Auth Error Rate**: > 5% of total auth checks +- **Session Check Duration**: > 2 seconds average +- **Cache Miss Rate**: > 80% (indicates caching issues) + +## Rollback Procedures + +### Immediate Rollback (if critical issues) + +1. **Stop Application** + ```bash + systemctl stop your-application + ``` + +2. **Restore Previous Code** + ```bash + git checkout previous-stable-tag + npm install + npm run build + ``` + +3. **Restart Application** + ```bash + systemctl start your-application + ``` + +4. **Verify Rollback** + - Test login functionality + - Check error logs + - Verify user sessions work + +### Partial Rollback (if specific component issues) + +1. **Identify Problem Component** + - Check which specific file is causing issues + - Review recent error logs + +2. **Rollback Specific Files** + ```bash + git checkout HEAD~1 -- middleware/authentication.ts + # or + git checkout HEAD~1 -- server/utils/session-manager.ts + ``` + +3. **Rebuild and Test** + ```bash + npm run build + systemctl restart your-application + ``` + +## Troubleshooting + +### Common Issues + +1. **Users Still Getting Logged Out at 2 Minutes** + - Check if SessionManager is being used + - Verify cache expiry changes are active + - Look for timing synchronization issues + +2. **Session Check Errors** + - Check network connectivity to Keycloak + - Verify environment variables are set + - Check Keycloak circuit breaker status + +3. **Performance Issues** + - Monitor session check durations + - Check cache hit rates + - Verify request deduplication is working + +### Debug Commands + +```bash +# Check session manager cache stats +curl https://your-domain.com/api/debug/session-cache-stats + +# Monitor real-time auth logs +tail -f /var/log/application.log | grep -E "(SESSION|AUTH_REFRESH|MIDDLEWARE)" + +# Check Keycloak connectivity +curl https://your-domain.com/api/debug/test-keycloak-connectivity +``` + +## Success Criteria + +The deployment is considered successful when: + +1. **No 2-Minute Logouts**: Users can navigate freely after 2 minutes +2. **Improved Error Handling**: Network issues don't cause immediate logouts +3. **Better Performance**: Session checks complete faster due to caching +4. **Enhanced Logging**: Detailed logs help with debugging future issues +5. **Graceful Degradation**: System handles transient failures elegantly + +## Contact Information + +For issues or questions regarding this deployment: + +- **Technical Lead**: [Your Name] +- **Emergency Contact**: [Emergency Number] +- **Documentation**: This file and related docs in `/docs/` directory + +## Appendix + +### Environment Variables Required + +```env +KEYCLOAK_CLIENT_SECRET=your-secret-key +COOKIE_DOMAIN=.portnimara.dev +``` + +### Log Examples + +Successful session check: +``` +[SESSION_MANAGER:abc123] Session check completed: {"authenticated":true,"reason":null,"fromCache":false} +``` + +Cache hit: +``` +[SESSION_MANAGER:def456] Using cached session (age: 45 seconds) +``` + +Network error with grace period: +``` +[SESSION_MANAGER:ghi789] Using cached result due to network error +``` + +### Performance Benchmarks + +- **Session Check Duration**: < 500ms average +- **Cache Hit Rate**: > 70% +- **Authentication Success Rate**: > 99% +- **Network Error Recovery**: < 5 seconds diff --git a/middleware/authentication.ts b/middleware/authentication.ts index 6006adf..944f23c 100644 --- a/middleware/authentication.ts +++ b/middleware/authentication.ts @@ -1,3 +1,5 @@ +import { sessionManager } from '~/server/utils/session-manager' + export default defineNuxtRouteMiddleware(async (to) => { // Skip auth for SSR if (import.meta.server) return; @@ -17,67 +19,55 @@ export default defineNuxtRouteMiddleware(async (to) => { console.log('[MIDDLEWARE] Checking authentication for route:', to.path); - // Use a cached auth state to avoid excessive API calls + // Use session manager for centralized session handling const nuxtApp = useNuxtApp(); const cacheKey = 'auth:session:cache'; - const cacheExpiry = 2 * 60 * 1000; // 2 minutes cache - reduced to prevent stale auth state + const baseExpiry = 3 * 60 * 1000; // 3 minutes base cache + const jitter = Math.floor(Math.random() * 10000); // 0-10 seconds jitter + const cacheExpiry = baseExpiry + jitter; // Prevent thundering herd - // Check if we have a cached session - const cachedSession = nuxtApp.payload.data?.[cacheKey]; - const now = Date.now(); - - if (cachedSession && cachedSession.timestamp && (now - cachedSession.timestamp) < cacheExpiry) { - console.log('[MIDDLEWARE] Using cached session (age:', Math.round((now - cachedSession.timestamp) / 1000), 'seconds)'); - if (cachedSession.authenticated && cachedSession.user) { - // Store auth state for components - if (!nuxtApp.payload.data) { - nuxtApp.payload.data = {}; - } - nuxtApp.payload.data.authState = { - user: cachedSession.user, - authenticated: cachedSession.authenticated, - groups: cachedSession.groups || [] - }; - return; - } - return navigateTo('/login'); - } - try { - // Check Keycloak authentication via session API with timeout and retries - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 10000); // 10 second timeout - - const sessionData = await $fetch('/api/auth/session', { - signal: controller.signal, - retry: 2, - retryDelay: 1000, - onRetry: ({ retries }: { retries: number }) => { - console.log(`[MIDDLEWARE] Retrying auth check (attempt ${retries + 1})`) - }, - onResponseError({ response }) { - // Clear cache on auth errors - if (response.status === 401 || response.status === 403) { - console.log('[MIDDLEWARE] Auth error detected, clearing cache') - delete nuxtApp.payload.data[cacheKey]; - delete nuxtApp.payload.data.authState; + // Use SessionManager for deduped session checks + const sessionData = await sessionManager.checkSession({ + nuxtApp, + cacheKey, + cacheExpiry, + fetchFn: async () => { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10000); // 10 second timeout + + try { + const result = await $fetch('/api/auth/session', { + signal: controller.signal, + retry: 2, + retryDelay: 1000, + onRetry: ({ retries }: { retries: number }) => { + console.log(`[MIDDLEWARE] Retrying auth check (attempt ${retries + 1})`) + }, + onResponseError({ response }) { + // Clear cache on auth errors + if (response.status === 401 || response.status === 403) { + console.log('[MIDDLEWARE] Auth error detected, clearing cache') + sessionManager.clearCache(); + delete nuxtApp.payload.data?.authState; + } + } + }) as any; + + clearTimeout(timeout); + return result; + } catch (error) { + clearTimeout(timeout); + throw error; } } - }) as any; + }); - clearTimeout(timeout); - - // Cache the session data + // Store auth state for components if (!nuxtApp.payload.data) { nuxtApp.payload.data = {}; } - nuxtApp.payload.data[cacheKey] = { - ...sessionData, - timestamp: now - }; - - // Store auth state for components nuxtApp.payload.data.authState = { user: sessionData.user, authenticated: sessionData.authenticated, @@ -88,7 +78,9 @@ export default defineNuxtRouteMiddleware(async (to) => { authenticated: sessionData.authenticated, hasUser: !!sessionData.user, userId: sessionData.user?.id, - groups: sessionData.groups || [] + groups: sessionData.groups || [], + fromCache: sessionData.fromCache, + reason: sessionData.reason }); if (sessionData.authenticated && sessionData.user) { @@ -110,32 +102,10 @@ export default defineNuxtRouteMiddleware(async (to) => { } catch (error: any) { console.error('[MIDDLEWARE] Auth check failed:', error); - // If it's a network error or timeout, check if we have a recent cached session - if (error.name === 'AbortError' || error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT') { - console.log('[MIDDLEWARE] Network error, checking for recent cache'); - const recentCache = nuxtApp.payload.data?.[cacheKey]; - if (recentCache && recentCache.timestamp && (now - recentCache.timestamp) < 5 * 60 * 1000) { // 5 minutes grace period - reduced from 30 - console.log('[MIDDLEWARE] Using recent cache despite network error (age:', Math.round((now - recentCache.timestamp) / 1000), 'seconds)'); - if (recentCache.authenticated && recentCache.user) { - // Store auth state for components - if (!nuxtApp.payload.data) { - nuxtApp.payload.data = {}; - } - nuxtApp.payload.data.authState = { - user: recentCache.user, - authenticated: recentCache.authenticated, - groups: recentCache.groups || [] - }; - - // Show a warning toast if cache is older than 2 minutes - if ((now - recentCache.timestamp) > 2 * 60 * 1000) { - const toast = useToast(); - toast.warning('Network connectivity issue - using cached authentication'); - } - - return; - } - } + // Show warning for cached results due to network errors + if (error.reason === 'NETWORK_ERROR_CACHED') { + const toast = useToast(); + toast.warning('Network connectivity issue - using cached authentication'); } return navigateTo('/login'); diff --git a/plugins/01.auth-refresh.client.ts b/plugins/01.auth-refresh.client.ts index 5169994..7ff550f 100644 --- a/plugins/01.auth-refresh.client.ts +++ b/plugins/01.auth-refresh.client.ts @@ -209,30 +209,42 @@ export default defineNuxtPlugin(() => { }) } - // Add periodic session validation (every 2 minutes) + // Add periodic session validation (every 2 minutes with offset) let validationInterval: NodeJS.Timeout | null = null + let isValidating = false // Prevent concurrent validations onMounted(() => { - validationInterval = setInterval(async () => { - console.log('[AUTH_REFRESH] Performing periodic session validation') - - try { - const response = await fetch('/api/auth/session', { - headers: { - 'Cache-Control': 'no-cache', - 'Pragma': 'no-cache' - } - }) + // Add random offset to prevent all clients checking at once + const randomOffset = Math.floor(Math.random() * 5000) // 0-5 seconds + + setTimeout(() => { + validationInterval = setInterval(async () => { + if (isValidating) return // Skip if already validating - if (!response.ok || response.status === 401) { - console.log('[AUTH_REFRESH] Session invalid during periodic check') - clearInterval(validationInterval!) - await navigateTo('/login') + isValidating = true + console.log('[AUTH_REFRESH] Performing periodic session validation') + + try { + const response = await fetch('/api/auth/session', { + headers: { + 'Cache-Control': 'no-cache', + 'Pragma': 'no-cache' + } + }) + + if (!response.ok || response.status === 401) { + console.log('[AUTH_REFRESH] Session invalid during periodic check') + clearInterval(validationInterval!) + await navigateTo('/login') + } + } catch (error) { + console.error('[AUTH_REFRESH] Periodic validation error:', error) + // Don't logout on network errors - let middleware handle it + } finally { + isValidating = false } - } catch (error) { - console.error('[AUTH_REFRESH] Periodic validation error:', error) - } - }, 2 * 60 * 1000) // Every 2 minutes + }, 2 * 60 * 1000) // Keep at 2 minutes + }, randomOffset) }) // Clean up timers on plugin destruction diff --git a/server/api/auth/refresh.ts b/server/api/auth/refresh.ts index 1b1d36a..ea8b115 100644 --- a/server/api/auth/refresh.ts +++ b/server/api/auth/refresh.ts @@ -2,14 +2,15 @@ import { keycloakClient } from '~/server/utils/keycloak-client' export default defineEventHandler(async (event) => { const startTime = Date.now() - console.log('[REFRESH] Processing token refresh request') + const requestId = Math.random().toString(36).substring(7) + console.log(`[REFRESH:${requestId}] Processing token refresh request`) try { // Get current session const oidcSession = getCookie(event, 'nuxt-oidc-auth') if (!oidcSession) { - console.error('[REFRESH] No session found') + console.error(`[REFRESH:${requestId}] No session found`) throw createError({ statusCode: 401, statusMessage: 'No session found' @@ -20,7 +21,7 @@ export default defineEventHandler(async (event) => { try { sessionData = JSON.parse(oidcSession) } catch (parseError) { - console.error('[REFRESH] Failed to parse session:', parseError) + console.error(`[REFRESH:${requestId}] Failed to parse session:`, parseError) throw createError({ statusCode: 401, statusMessage: 'Invalid session format' @@ -29,7 +30,7 @@ export default defineEventHandler(async (event) => { // Check if we have a refresh token if (!sessionData.refreshToken) { - console.error('[REFRESH] No refresh token available') + console.error(`[REFRESH:${requestId}] No refresh token available`) throw createError({ statusCode: 401, statusMessage: 'No refresh token available' @@ -39,24 +40,48 @@ export default defineEventHandler(async (event) => { // Validate environment variables const clientSecret = process.env.KEYCLOAK_CLIENT_SECRET if (!clientSecret) { - console.error('[REFRESH] KEYCLOAK_CLIENT_SECRET not configured') + console.error(`[REFRESH:${requestId}] KEYCLOAK_CLIENT_SECRET not configured`) throw createError({ statusCode: 500, statusMessage: 'Authentication service misconfigured' }) } - // Use refresh token to get new access token with retry logic - console.log('[REFRESH] Using Keycloak client for token refresh...') + // Use refresh token to get new access token with enhanced error handling + console.log(`[REFRESH:${requestId}] Using Keycloak client for token refresh...`) const tokenResponse = await keycloakClient.refreshAccessToken(sessionData.refreshToken) + .catch((error: any) => { + // Check if it's a transient error + if (error.statusMessage === 'KEYCLOAK_TEMPORARILY_UNAVAILABLE') { + console.log(`[REFRESH:${requestId}] Keycloak temporarily unavailable, using grace period`) + // Return current session with extended grace period + return { + success: true, + expiresAt: sessionData.expiresAt, + gracePeriod: true + } + } + throw error // Re-throw for permanent failures + }) const refreshDuration = Date.now() - startTime - console.log(`[REFRESH] Token refresh successful in ${refreshDuration}ms:`, { + console.log(`[REFRESH:${requestId}] Token refresh successful in ${refreshDuration}ms:`, { hasAccessToken: !!tokenResponse.access_token, hasRefreshToken: !!tokenResponse.refresh_token, - expiresIn: tokenResponse.expires_in + expiresIn: tokenResponse.expires_in, + gracePeriod: tokenResponse.gracePeriod }) + // Handle grace period response + if (tokenResponse.gracePeriod) { + console.log(`[REFRESH:${requestId}] Using grace period - session extended`) + return { + success: true, + expiresAt: tokenResponse.expiresAt, + gracePeriod: true + } + } + // Update session with new tokens const updatedSessionData = { ...sessionData, @@ -79,7 +104,7 @@ export default defineEventHandler(async (event) => { path: '/' }) - console.log('[REFRESH] Session updated successfully') + console.log(`[REFRESH:${requestId}] Session updated successfully`) return { success: true, @@ -87,14 +112,17 @@ export default defineEventHandler(async (event) => { } } catch (error: any) { - console.error('[REFRESH] Token refresh failed:', error) + console.error(`[REFRESH:${requestId}] Token refresh failed:`, error) - // Clear invalid session - const cookieDomain = process.env.COOKIE_DOMAIN || '.portnimara.dev'; - deleteCookie(event, 'nuxt-oidc-auth', { - domain: cookieDomain, - path: '/' - }) + // Only clear session for permanent failures + if (error.statusMessage === 'REFRESH_TOKEN_INVALID') { + console.log(`[REFRESH:${requestId}] Clearing session due to invalid refresh token`) + const cookieDomain = process.env.COOKIE_DOMAIN || '.portnimara.dev'; + deleteCookie(event, 'nuxt-oidc-auth', { + domain: cookieDomain, + path: '/' + }) + } throw createError({ statusCode: 401, diff --git a/server/api/auth/session.ts b/server/api/auth/session.ts index 431c5f8..f2d9507 100644 --- a/server/api/auth/session.ts +++ b/server/api/auth/session.ts @@ -1,22 +1,33 @@ export default defineEventHandler(async (event) => { - console.log('[SESSION] Checking authentication session...') + const requestId = Math.random().toString(36).substring(7) + const startTime = Date.now() + console.log(`[SESSION:${requestId}] Checking authentication session...`) // Check OIDC/Keycloak authentication only try { const oidcSessionCookie = getCookie(event, 'nuxt-oidc-auth') if (!oidcSessionCookie) { - console.log('[SESSION] No OIDC session cookie found') - return { user: null, authenticated: false, groups: [] } + console.log(`[SESSION:${requestId}] No OIDC session cookie found`) + return { + user: null, + authenticated: false, + groups: [], + reason: 'NO_SESSION_COOKIE', + requestId + } } - console.log('[SESSION] OIDC session cookie found, parsing...') + console.log(`[SESSION:${requestId}] OIDC session cookie found, parsing...`) let sessionData try { // Parse the session data + const parseStart = Date.now() sessionData = JSON.parse(oidcSessionCookie) - console.log('[SESSION] Session data parsed successfully:', { + const parseTime = Date.now() - parseStart + + console.log(`[SESSION:${requestId}] Session data parsed successfully in ${parseTime}ms:`, { hasUser: !!sessionData.user, hasAccessToken: !!sessionData.accessToken, hasIdToken: !!sessionData.idToken, @@ -25,19 +36,25 @@ export default defineEventHandler(async (event) => { timeUntilExpiry: sessionData.expiresAt ? sessionData.expiresAt - Date.now() : 'unknown' }) } catch (parseError) { - console.error('[SESSION] Failed to parse session cookie:', parseError) + console.error(`[SESSION:${requestId}] Failed to parse session cookie:`, parseError) // Clear invalid session const cookieDomain = process.env.COOKIE_DOMAIN || '.portnimara.dev'; deleteCookie(event, 'nuxt-oidc-auth', { domain: cookieDomain, path: '/' }) - return { user: null, authenticated: false, groups: [] } + return { + user: null, + authenticated: false, + groups: [], + reason: 'INVALID_SESSION_FORMAT', + requestId + } } // Validate session structure if (!sessionData.user || !sessionData.accessToken) { - console.error('[SESSION] Invalid session structure:', { + console.error(`[SESSION:${requestId}] Invalid session structure:`, { hasUser: !!sessionData.user, hasAccessToken: !!sessionData.accessToken }) @@ -46,12 +63,18 @@ export default defineEventHandler(async (event) => { domain: cookieDomain, path: '/' }) - return { user: null, authenticated: false, groups: [] } + return { + user: null, + authenticated: false, + groups: [], + reason: 'INVALID_SESSION_STRUCTURE', + requestId + } } // Check if session is still valid if (sessionData.expiresAt && Date.now() > sessionData.expiresAt) { - console.log('[SESSION] Session expired:', { + console.log(`[SESSION:${requestId}] Session expired:`, { expiresAt: sessionData.expiresAt, currentTime: Date.now(), expiredSince: Date.now() - sessionData.expiresAt @@ -62,7 +85,13 @@ export default defineEventHandler(async (event) => { domain: cookieDomain, path: '/' }) - return { user: null, authenticated: false, groups: [] } + return { + user: null, + authenticated: false, + groups: [], + reason: 'SESSION_EXPIRED', + requestId + } } // Extract groups from ID token diff --git a/server/utils/keycloak-client.ts b/server/utils/keycloak-client.ts index 59ccd14..05be38c 100644 --- a/server/utils/keycloak-client.ts +++ b/server/utils/keycloak-client.ts @@ -184,21 +184,44 @@ class KeycloakClient { const tokenUrl = 'https://auth.portnimara.dev/realms/client-portal/protocol/openid-connect/token' - return this.fetch(tokenUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: new URLSearchParams({ - grant_type: 'refresh_token', - client_id: 'client-portal', - client_secret: clientSecret, - refresh_token: refreshToken - }).toString() - }, { - timeout: 15000, - retries: 1 // Only 1 retry for refresh operations - }) + try { + const response = await this.fetch(tokenUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + client_id: 'client-portal', + client_secret: clientSecret, + refresh_token: refreshToken + }).toString() + }, { + timeout: 15000, + retries: 2 // Increased from 1 + }) + + // Log successful refresh + console.log('[KEYCLOAK_CLIENT] Token refresh successful') + return response + } catch (error: any) { + // Distinguish between error types + if (error.status === 400 || error.status === 401) { + // Refresh token expired or invalid + console.error('[KEYCLOAK_CLIENT] Refresh token invalid:', error.status) + throw createError({ + statusCode: 401, + statusMessage: 'REFRESH_TOKEN_INVALID' + }) + } + + // Network or server error - might be transient + console.error('[KEYCLOAK_CLIENT] Refresh failed (transient?):', error) + throw createError({ + statusCode: 503, + statusMessage: 'KEYCLOAK_TEMPORARILY_UNAVAILABLE' + }) + } } getCircuitBreakerStatus() { diff --git a/server/utils/session-manager.ts b/server/utils/session-manager.ts new file mode 100644 index 0000000..d18ae3d --- /dev/null +++ b/server/utils/session-manager.ts @@ -0,0 +1,272 @@ +interface SessionCheckOptions { + nuxtApp?: any + cacheKey?: string + cacheExpiry?: number + fetchFn?: () => Promise + bypassCache?: boolean +} + +interface SessionResult { + user: any + authenticated: boolean + groups: string[] + reason?: string + fromCache?: boolean + timestamp?: number +} + +interface SessionCache { + result: SessionResult + timestamp: number + expiresAt: number +} + +/** + * Centralized session management with request deduplication and caching + */ +class SessionManager { + private static instance: SessionManager + private sessionCheckPromise: Promise | null = null + private sessionCheckLock = false + private lastCheckTime = 0 + private readonly minCheckInterval = 1000 // 1 second minimum between checks + private sessionCache: Map = new Map() + private readonly defaultCacheExpiry = 3 * 60 * 1000 // 3 minutes + private readonly gracePeriod = 5 * 60 * 1000 // 5 minutes grace period + + static getInstance(): SessionManager { + if (!SessionManager.instance) { + SessionManager.instance = new SessionManager() + } + return SessionManager.instance + } + + /** + * Check session with request deduplication and caching + */ + async checkSession(options: SessionCheckOptions = {}): Promise { + const requestId = Math.random().toString(36).substring(7) + console.log(`[SESSION_MANAGER:${requestId}] Session check requested`) + + // Use default cache key if not provided + const cacheKey = options.cacheKey || 'default' + const cacheExpiry = options.cacheExpiry || this.defaultCacheExpiry + + // Check cache first (unless bypassing) + if (!options.bypassCache) { + const cached = this.getCachedSession(cacheKey) + if (cached) { + console.log(`[SESSION_MANAGER:${requestId}] Using cached session (age: ${Math.round((Date.now() - cached.timestamp) / 1000)}s)`) + return cached.result + } + } + + // Implement request deduplication + if (this.sessionCheckPromise) { + console.log(`[SESSION_MANAGER:${requestId}] Using in-flight session check`) + return this.sessionCheckPromise + } + + // Prevent rapid successive checks + const now = Date.now() + if (now - this.lastCheckTime < this.minCheckInterval) { + console.log(`[SESSION_MANAGER:${requestId}] Rate limiting - using last cached result`) + const cached = this.getCachedSession(cacheKey) + if (cached) { + return cached.result + } + } + + this.lastCheckTime = now + this.sessionCheckPromise = this.performSessionCheck(options, requestId, cacheKey, cacheExpiry) + + try { + const result = await this.sessionCheckPromise + console.log(`[SESSION_MANAGER:${requestId}] Session check completed:`, { + authenticated: result.authenticated, + reason: result.reason, + fromCache: result.fromCache + }) + return result + } finally { + this.sessionCheckPromise = null + } + } + + /** + * Get cached session if valid + */ + private getCachedSession(cacheKey: string): SessionCache | null { + const cached = this.sessionCache.get(cacheKey) + if (!cached) return null + + const now = Date.now() + + // Check if cache is still valid + if (now < cached.expiresAt) { + return cached + } + + // Check if we're within grace period for network issues + if (now - cached.timestamp < this.gracePeriod) { + console.log(`[SESSION_MANAGER] Cache expired but within grace period`) + return cached + } + + // Remove expired cache + this.sessionCache.delete(cacheKey) + return null + } + + /** + * Perform actual session check + */ + private async performSessionCheck( + options: SessionCheckOptions, + requestId: string, + cacheKey: string, + cacheExpiry: number + ): Promise { + const startTime = Date.now() + + try { + let result: SessionResult + + if (options.fetchFn) { + console.log(`[SESSION_MANAGER:${requestId}] Using custom fetch function`) + result = await options.fetchFn() + } else { + console.log(`[SESSION_MANAGER:${requestId}] Using default session check`) + result = await this.defaultSessionCheck() + } + + // Add metadata + result.timestamp = Date.now() + result.fromCache = false + + // Cache the result + this.cacheSessionResult(cacheKey, result, cacheExpiry) + + const duration = Date.now() - startTime + console.log(`[SESSION_MANAGER:${requestId}] Session check completed in ${duration}ms`) + + return result + } catch (error: any) { + console.error(`[SESSION_MANAGER:${requestId}] Session check failed:`, error) + + // Try to return cached result during network errors + const cached = this.getCachedSession(cacheKey) + if (cached && this.isNetworkError(error)) { + console.log(`[SESSION_MANAGER:${requestId}] Using cached result due to network error`) + return { + ...cached.result, + reason: 'NETWORK_ERROR_CACHED' + } + } + + // Return failed result + return { + user: null, + authenticated: false, + groups: [], + reason: error.message || 'SESSION_CHECK_FAILED', + timestamp: Date.now() + } + } + } + + /** + * Default session check implementation + */ + private async defaultSessionCheck(): Promise { + // This would normally make a call to /api/auth/session + // For now, return a placeholder - this will be replaced by actual API call + throw new Error('Default session check not implemented - use fetchFn option') + } + + /** + * Cache session result + */ + private cacheSessionResult(cacheKey: string, result: SessionResult, cacheExpiry: number): void { + const jitter = Math.floor(Math.random() * 10000) // 0-10 seconds jitter + const expiresAt = Date.now() + cacheExpiry + jitter + + this.sessionCache.set(cacheKey, { + result, + timestamp: Date.now(), + expiresAt + }) + + console.log(`[SESSION_MANAGER] Cached session result for ${cacheExpiry + jitter}ms`) + } + + /** + * Check if error is a network error + */ + private isNetworkError(error: any): boolean { + return error.code === 'ECONNREFUSED' || + error.code === 'ETIMEDOUT' || + error.name === 'AbortError' || + error.code === 'ENOTFOUND' || + (error.status >= 500 && error.status < 600) + } + + /** + * Validate session (used by auth refresh plugin) + */ + async validateSession(): Promise { + return this.checkSession({ + cacheKey: 'validation', + bypassCache: true, // Always fresh check for validation + fetchFn: async () => { + // This will be implemented to call the session API + const response = await fetch('/api/auth/session', { + headers: { + 'Cache-Control': 'no-cache', + 'Pragma': 'no-cache' + } + }) + + if (!response.ok) { + throw new Error(`Session validation failed: ${response.status}`) + } + + return response.json() + } + }) + } + + /** + * Clear all cached sessions + */ + clearCache(): void { + this.sessionCache.clear() + console.log('[SESSION_MANAGER] Session cache cleared') + } + + /** + * Get cache statistics + */ + getCacheStats(): { entries: number; oldestEntry: number | null; newestEntry: number | null } { + const entries = this.sessionCache.size + let oldestEntry: number | null = null + let newestEntry: number | null = null + + for (const cache of this.sessionCache.values()) { + if (oldestEntry === null || cache.timestamp < oldestEntry) { + oldestEntry = cache.timestamp + } + if (newestEntry === null || cache.timestamp > newestEntry) { + newestEntry = cache.timestamp + } + } + + return { entries, oldestEntry, newestEntry } + } +} + +// Export singleton instance +export const sessionManager = SessionManager.getInstance() + +// Export class for testing +export { SessionManager } diff --git a/tests/auth/session-manager.test.ts b/tests/auth/session-manager.test.ts new file mode 100644 index 0000000..d74ee01 --- /dev/null +++ b/tests/auth/session-manager.test.ts @@ -0,0 +1,268 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { SessionManager } from '~/server/utils/session-manager' + +describe('SessionManager', () => { + let sessionManager: SessionManager + let mockFetch: any + + beforeEach(() => { + sessionManager = SessionManager.getInstance() + sessionManager.clearCache() + mockFetch = vi.fn() + vi.clearAllMocks() + }) + + afterEach(() => { + sessionManager.clearCache() + }) + + describe('Request Deduplication', () => { + it('should deduplicate concurrent requests', async () => { + const mockResponse = { authenticated: true, user: { id: '123' }, groups: [] } + mockFetch.mockResolvedValue(mockResponse) + + // Make multiple concurrent requests + const promises = [ + sessionManager.checkSession({ fetchFn: mockFetch }), + sessionManager.checkSession({ fetchFn: mockFetch }), + sessionManager.checkSession({ fetchFn: mockFetch }) + ] + + const results = await Promise.all(promises) + + // Should only call fetch once + expect(mockFetch).toHaveBeenCalledTimes(1) + expect(results.every(r => r.authenticated)).toBe(true) + expect(results.every(r => r.user.id === '123')).toBe(true) + }) + + it('should handle failed requests and not cache errors', async () => { + const error = new Error('Network error') + mockFetch.mockRejectedValue(error) + + const result = await sessionManager.checkSession({ fetchFn: mockFetch }) + + expect(result.authenticated).toBe(false) + expect(result.reason).toBe('Network error') + expect(mockFetch).toHaveBeenCalledTimes(1) + }) + + it('should rate limit rapid successive requests', async () => { + const mockResponse = { authenticated: true, user: { id: '123' }, groups: [] } + mockFetch.mockResolvedValue(mockResponse) + + // First request + await sessionManager.checkSession({ fetchFn: mockFetch }) + + // Immediate second request should use cache + const secondResult = await sessionManager.checkSession({ fetchFn: mockFetch }) + + expect(mockFetch).toHaveBeenCalledTimes(1) + expect(secondResult.authenticated).toBe(true) + }) + }) + + describe('Caching', () => { + it('should cache successful responses', async () => { + const mockResponse = { authenticated: true, user: { id: '123' }, groups: [] } + mockFetch.mockResolvedValue(mockResponse) + + // First request + const firstResult = await sessionManager.checkSession({ + fetchFn: mockFetch, + cacheKey: 'test-cache' + }) + + // Second request should use cache + const secondResult = await sessionManager.checkSession({ + fetchFn: mockFetch, + cacheKey: 'test-cache' + }) + + expect(mockFetch).toHaveBeenCalledTimes(1) + expect(firstResult.authenticated).toBe(true) + expect(secondResult.authenticated).toBe(true) + expect(secondResult.fromCache).toBe(true) + }) + + it('should respect cache expiry', async () => { + const mockResponse = { authenticated: true, user: { id: '123' }, groups: [] } + mockFetch.mockResolvedValue(mockResponse) + + // First request with very short cache expiry + await sessionManager.checkSession({ + fetchFn: mockFetch, + cacheKey: 'short-cache', + cacheExpiry: 100 // 100ms + }) + + // Wait for cache to expire + await new Promise(resolve => setTimeout(resolve, 150)) + + // Second request should make new fetch + await sessionManager.checkSession({ + fetchFn: mockFetch, + cacheKey: 'short-cache', + cacheExpiry: 100 + }) + + expect(mockFetch).toHaveBeenCalledTimes(2) + }) + + it('should use grace period for network errors', async () => { + const mockResponse = { authenticated: true, user: { id: '123' }, groups: [] } + mockFetch.mockResolvedValueOnce(mockResponse) + + // First successful request + await sessionManager.checkSession({ + fetchFn: mockFetch, + cacheKey: 'grace-test' + }) + + // Mock network error + const networkError = new Error('Network error') + networkError.code = 'ECONNREFUSED' + mockFetch.mockRejectedValue(networkError) + + // Second request should use cached result due to network error + const result = await sessionManager.checkSession({ + fetchFn: mockFetch, + cacheKey: 'grace-test' + }) + + expect(result.authenticated).toBe(true) + expect(result.reason).toBe('NETWORK_ERROR_CACHED') + }) + }) + + describe('Session Validation', () => { + it('should validate session with fresh check', async () => { + const mockResponse = { authenticated: true, user: { id: '123' }, groups: [] } + + // Mock the fetch API + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockResponse) + }) + + const result = await sessionManager.validateSession() + + expect(result.authenticated).toBe(true) + expect(global.fetch).toHaveBeenCalledWith('/api/auth/session', { + headers: { + 'Cache-Control': 'no-cache', + 'Pragma': 'no-cache' + } + }) + }) + + it('should handle validation failure', async () => { + // Mock failed fetch + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 401 + }) + + const result = await sessionManager.validateSession() + + expect(result.authenticated).toBe(false) + expect(result.reason).toBe('Session validation failed: 401') + }) + }) + + describe('Cache Management', () => { + it('should clear cache', async () => { + const mockResponse = { authenticated: true, user: { id: '123' }, groups: [] } + mockFetch.mockResolvedValue(mockResponse) + + // Cache some data + await sessionManager.checkSession({ + fetchFn: mockFetch, + cacheKey: 'clear-test' + }) + + // Clear cache + sessionManager.clearCache() + + // Next request should make fresh fetch + await sessionManager.checkSession({ + fetchFn: mockFetch, + cacheKey: 'clear-test' + }) + + expect(mockFetch).toHaveBeenCalledTimes(2) + }) + + it('should provide cache statistics', async () => { + const mockResponse = { authenticated: true, user: { id: '123' }, groups: [] } + mockFetch.mockResolvedValue(mockResponse) + + const initialStats = sessionManager.getCacheStats() + expect(initialStats.entries).toBe(0) + + // Add some cache entries + await sessionManager.checkSession({ + fetchFn: mockFetch, + cacheKey: 'stats-test-1' + }) + await sessionManager.checkSession({ + fetchFn: mockFetch, + cacheKey: 'stats-test-2' + }) + + const finalStats = sessionManager.getCacheStats() + expect(finalStats.entries).toBe(2) + expect(finalStats.oldestEntry).toBeDefined() + expect(finalStats.newestEntry).toBeDefined() + }) + }) + + describe('Error Handling', () => { + it('should identify network errors correctly', async () => { + const networkErrors = [ + { code: 'ECONNREFUSED' }, + { code: 'ETIMEDOUT' }, + { name: 'AbortError' }, + { code: 'ENOTFOUND' }, + { status: 503 } + ] + + for (const error of networkErrors) { + mockFetch.mockRejectedValue(error) + + const result = await sessionManager.checkSession({ + fetchFn: mockFetch, + cacheKey: `network-error-${error.code || error.name || error.status}` + }) + + expect(result.authenticated).toBe(false) + expect(result.reason).toBe(error.message || 'SESSION_CHECK_FAILED') + } + }) + + it('should handle non-network errors without grace period', async () => { + const mockResponse = { authenticated: true, user: { id: '123' }, groups: [] } + mockFetch.mockResolvedValueOnce(mockResponse) + + // First successful request + await sessionManager.checkSession({ + fetchFn: mockFetch, + cacheKey: 'non-network-error' + }) + + // Mock non-network error + const authError = new Error('Auth error') + authError.status = 401 + mockFetch.mockRejectedValue(authError) + + // Second request should not use cached result for auth errors + const result = await sessionManager.checkSession({ + fetchFn: mockFetch, + cacheKey: 'non-network-error' + }) + + expect(result.authenticated).toBe(false) + expect(result.reason).toBe('Auth error') + }) + }) +})