Refactor authentication to use centralized session manager

Extract session management logic from middleware into reusable SessionManager utility to improve reliability, reduce code duplication, and prevent thundering herd issues with jittered cache expiry.
This commit is contained in:
Matt 2025-07-11 14:43:50 -04:00
parent bf2361050f
commit c6f81a6686
8 changed files with 1051 additions and 139 deletions

View File

@ -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=<session-cookie>"
```
### 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

View File

@ -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
// 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');
}
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
try {
// Check Keycloak authentication via session API with timeout and retries
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000); // 10 second timeout
// 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
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;
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');

View File

@ -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')
// Add random offset to prevent all clients checking at once
const randomOffset = Math.floor(Math.random() * 5000) // 0-5 seconds
try {
const response = await fetch('/api/auth/session', {
headers: {
'Cache-Control': 'no-cache',
'Pragma': 'no-cache'
setTimeout(() => {
validationInterval = setInterval(async () => {
if (isValidating) return // Skip if already validating
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')
}
})
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

View File

@ -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,

View File

@ -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

View File

@ -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() {

View File

@ -0,0 +1,272 @@
interface SessionCheckOptions {
nuxtApp?: any
cacheKey?: string
cacheExpiry?: number
fetchFn?: () => Promise<any>
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<SessionResult> | null = null
private sessionCheckLock = false
private lastCheckTime = 0
private readonly minCheckInterval = 1000 // 1 second minimum between checks
private sessionCache: Map<string, SessionCache> = 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<SessionResult> {
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<SessionResult> {
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<SessionResult> {
// 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<SessionResult> {
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 }

View File

@ -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')
})
})
})