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:
parent
bf2361050f
commit
c6f81a6686
|
|
@ -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
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { sessionManager } from '~/server/utils/session-manager'
|
||||||
|
|
||||||
export default defineNuxtRouteMiddleware(async (to) => {
|
export default defineNuxtRouteMiddleware(async (to) => {
|
||||||
// Skip auth for SSR
|
// Skip auth for SSR
|
||||||
if (import.meta.server) return;
|
if (import.meta.server) return;
|
||||||
|
|
@ -17,67 +19,55 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
||||||
|
|
||||||
console.log('[MIDDLEWARE] Checking authentication for route:', to.path);
|
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 nuxtApp = useNuxtApp();
|
||||||
const cacheKey = 'auth:session:cache';
|
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 {
|
try {
|
||||||
// Check Keycloak authentication via session API with timeout and retries
|
// Use SessionManager for deduped session checks
|
||||||
const controller = new AbortController();
|
const sessionData = await sessionManager.checkSession({
|
||||||
const timeout = setTimeout(() => controller.abort(), 10000); // 10 second timeout
|
nuxtApp,
|
||||||
|
cacheKey,
|
||||||
const sessionData = await $fetch('/api/auth/session', {
|
cacheExpiry,
|
||||||
signal: controller.signal,
|
fetchFn: async () => {
|
||||||
retry: 2,
|
const controller = new AbortController();
|
||||||
retryDelay: 1000,
|
const timeout = setTimeout(() => controller.abort(), 10000); // 10 second timeout
|
||||||
onRetry: ({ retries }: { retries: number }) => {
|
|
||||||
console.log(`[MIDDLEWARE] Retrying auth check (attempt ${retries + 1})`)
|
try {
|
||||||
},
|
const result = await $fetch('/api/auth/session', {
|
||||||
onResponseError({ response }) {
|
signal: controller.signal,
|
||||||
// Clear cache on auth errors
|
retry: 2,
|
||||||
if (response.status === 401 || response.status === 403) {
|
retryDelay: 1000,
|
||||||
console.log('[MIDDLEWARE] Auth error detected, clearing cache')
|
onRetry: ({ retries }: { retries: number }) => {
|
||||||
delete nuxtApp.payload.data[cacheKey];
|
console.log(`[MIDDLEWARE] Retrying auth check (attempt ${retries + 1})`)
|
||||||
delete nuxtApp.payload.data.authState;
|
},
|
||||||
|
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);
|
// Store auth state for components
|
||||||
|
|
||||||
// Cache the session data
|
|
||||||
if (!nuxtApp.payload.data) {
|
if (!nuxtApp.payload.data) {
|
||||||
nuxtApp.payload.data = {};
|
nuxtApp.payload.data = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
nuxtApp.payload.data[cacheKey] = {
|
|
||||||
...sessionData,
|
|
||||||
timestamp: now
|
|
||||||
};
|
|
||||||
|
|
||||||
// Store auth state for components
|
|
||||||
nuxtApp.payload.data.authState = {
|
nuxtApp.payload.data.authState = {
|
||||||
user: sessionData.user,
|
user: sessionData.user,
|
||||||
authenticated: sessionData.authenticated,
|
authenticated: sessionData.authenticated,
|
||||||
|
|
@ -88,7 +78,9 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
||||||
authenticated: sessionData.authenticated,
|
authenticated: sessionData.authenticated,
|
||||||
hasUser: !!sessionData.user,
|
hasUser: !!sessionData.user,
|
||||||
userId: sessionData.user?.id,
|
userId: sessionData.user?.id,
|
||||||
groups: sessionData.groups || []
|
groups: sessionData.groups || [],
|
||||||
|
fromCache: sessionData.fromCache,
|
||||||
|
reason: sessionData.reason
|
||||||
});
|
});
|
||||||
|
|
||||||
if (sessionData.authenticated && sessionData.user) {
|
if (sessionData.authenticated && sessionData.user) {
|
||||||
|
|
@ -110,32 +102,10 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('[MIDDLEWARE] Auth check failed:', error);
|
console.error('[MIDDLEWARE] Auth check failed:', error);
|
||||||
|
|
||||||
// If it's a network error or timeout, check if we have a recent cached session
|
// Show warning for cached results due to network errors
|
||||||
if (error.name === 'AbortError' || error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT') {
|
if (error.reason === 'NETWORK_ERROR_CACHED') {
|
||||||
console.log('[MIDDLEWARE] Network error, checking for recent cache');
|
const toast = useToast();
|
||||||
const recentCache = nuxtApp.payload.data?.[cacheKey];
|
toast.warning('Network connectivity issue - using cached authentication');
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return navigateTo('/login');
|
return navigateTo('/login');
|
||||||
|
|
|
||||||
|
|
@ -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 validationInterval: NodeJS.Timeout | null = null
|
||||||
|
let isValidating = false // Prevent concurrent validations
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
validationInterval = setInterval(async () => {
|
// Add random offset to prevent all clients checking at once
|
||||||
console.log('[AUTH_REFRESH] Performing periodic session validation')
|
const randomOffset = Math.floor(Math.random() * 5000) // 0-5 seconds
|
||||||
|
|
||||||
try {
|
setTimeout(() => {
|
||||||
const response = await fetch('/api/auth/session', {
|
validationInterval = setInterval(async () => {
|
||||||
headers: {
|
if (isValidating) return // Skip if already validating
|
||||||
'Cache-Control': 'no-cache',
|
|
||||||
'Pragma': 'no-cache'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok || response.status === 401) {
|
isValidating = true
|
||||||
console.log('[AUTH_REFRESH] Session invalid during periodic check')
|
console.log('[AUTH_REFRESH] Performing periodic session validation')
|
||||||
clearInterval(validationInterval!)
|
|
||||||
await navigateTo('/login')
|
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) {
|
}, 2 * 60 * 1000) // Keep at 2 minutes
|
||||||
console.error('[AUTH_REFRESH] Periodic validation error:', error)
|
}, randomOffset)
|
||||||
}
|
|
||||||
}, 2 * 60 * 1000) // Every 2 minutes
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Clean up timers on plugin destruction
|
// Clean up timers on plugin destruction
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,15 @@ import { keycloakClient } from '~/server/utils/keycloak-client'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const startTime = Date.now()
|
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 {
|
try {
|
||||||
// Get current session
|
// Get current session
|
||||||
const oidcSession = getCookie(event, 'nuxt-oidc-auth')
|
const oidcSession = getCookie(event, 'nuxt-oidc-auth')
|
||||||
|
|
||||||
if (!oidcSession) {
|
if (!oidcSession) {
|
||||||
console.error('[REFRESH] No session found')
|
console.error(`[REFRESH:${requestId}] No session found`)
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 401,
|
statusCode: 401,
|
||||||
statusMessage: 'No session found'
|
statusMessage: 'No session found'
|
||||||
|
|
@ -20,7 +21,7 @@ export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
sessionData = JSON.parse(oidcSession)
|
sessionData = JSON.parse(oidcSession)
|
||||||
} catch (parseError) {
|
} catch (parseError) {
|
||||||
console.error('[REFRESH] Failed to parse session:', parseError)
|
console.error(`[REFRESH:${requestId}] Failed to parse session:`, parseError)
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 401,
|
statusCode: 401,
|
||||||
statusMessage: 'Invalid session format'
|
statusMessage: 'Invalid session format'
|
||||||
|
|
@ -29,7 +30,7 @@ export default defineEventHandler(async (event) => {
|
||||||
|
|
||||||
// Check if we have a refresh token
|
// Check if we have a refresh token
|
||||||
if (!sessionData.refreshToken) {
|
if (!sessionData.refreshToken) {
|
||||||
console.error('[REFRESH] No refresh token available')
|
console.error(`[REFRESH:${requestId}] No refresh token available`)
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 401,
|
statusCode: 401,
|
||||||
statusMessage: 'No refresh token available'
|
statusMessage: 'No refresh token available'
|
||||||
|
|
@ -39,24 +40,48 @@ export default defineEventHandler(async (event) => {
|
||||||
// Validate environment variables
|
// Validate environment variables
|
||||||
const clientSecret = process.env.KEYCLOAK_CLIENT_SECRET
|
const clientSecret = process.env.KEYCLOAK_CLIENT_SECRET
|
||||||
if (!clientSecret) {
|
if (!clientSecret) {
|
||||||
console.error('[REFRESH] KEYCLOAK_CLIENT_SECRET not configured')
|
console.error(`[REFRESH:${requestId}] KEYCLOAK_CLIENT_SECRET not configured`)
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 500,
|
statusCode: 500,
|
||||||
statusMessage: 'Authentication service misconfigured'
|
statusMessage: 'Authentication service misconfigured'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use refresh token to get new access token with retry logic
|
// Use refresh token to get new access token with enhanced error handling
|
||||||
console.log('[REFRESH] Using Keycloak client for token refresh...')
|
console.log(`[REFRESH:${requestId}] Using Keycloak client for token refresh...`)
|
||||||
const tokenResponse = await keycloakClient.refreshAccessToken(sessionData.refreshToken)
|
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
|
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,
|
hasAccessToken: !!tokenResponse.access_token,
|
||||||
hasRefreshToken: !!tokenResponse.refresh_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
|
// Update session with new tokens
|
||||||
const updatedSessionData = {
|
const updatedSessionData = {
|
||||||
...sessionData,
|
...sessionData,
|
||||||
|
|
@ -79,7 +104,7 @@ export default defineEventHandler(async (event) => {
|
||||||
path: '/'
|
path: '/'
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log('[REFRESH] Session updated successfully')
|
console.log(`[REFRESH:${requestId}] Session updated successfully`)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|
@ -87,14 +112,17 @@ export default defineEventHandler(async (event) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('[REFRESH] Token refresh failed:', error)
|
console.error(`[REFRESH:${requestId}] Token refresh failed:`, error)
|
||||||
|
|
||||||
// Clear invalid session
|
// Only clear session for permanent failures
|
||||||
const cookieDomain = process.env.COOKIE_DOMAIN || '.portnimara.dev';
|
if (error.statusMessage === 'REFRESH_TOKEN_INVALID') {
|
||||||
deleteCookie(event, 'nuxt-oidc-auth', {
|
console.log(`[REFRESH:${requestId}] Clearing session due to invalid refresh token`)
|
||||||
domain: cookieDomain,
|
const cookieDomain = process.env.COOKIE_DOMAIN || '.portnimara.dev';
|
||||||
path: '/'
|
deleteCookie(event, 'nuxt-oidc-auth', {
|
||||||
})
|
domain: cookieDomain,
|
||||||
|
path: '/'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 401,
|
statusCode: 401,
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,33 @@
|
||||||
export default defineEventHandler(async (event) => {
|
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
|
// Check OIDC/Keycloak authentication only
|
||||||
try {
|
try {
|
||||||
const oidcSessionCookie = getCookie(event, 'nuxt-oidc-auth')
|
const oidcSessionCookie = getCookie(event, 'nuxt-oidc-auth')
|
||||||
|
|
||||||
if (!oidcSessionCookie) {
|
if (!oidcSessionCookie) {
|
||||||
console.log('[SESSION] No OIDC session cookie found')
|
console.log(`[SESSION:${requestId}] No OIDC session cookie found`)
|
||||||
return { user: null, authenticated: false, groups: [] }
|
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
|
let sessionData
|
||||||
try {
|
try {
|
||||||
// Parse the session data
|
// Parse the session data
|
||||||
|
const parseStart = Date.now()
|
||||||
sessionData = JSON.parse(oidcSessionCookie)
|
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,
|
hasUser: !!sessionData.user,
|
||||||
hasAccessToken: !!sessionData.accessToken,
|
hasAccessToken: !!sessionData.accessToken,
|
||||||
hasIdToken: !!sessionData.idToken,
|
hasIdToken: !!sessionData.idToken,
|
||||||
|
|
@ -25,19 +36,25 @@ export default defineEventHandler(async (event) => {
|
||||||
timeUntilExpiry: sessionData.expiresAt ? sessionData.expiresAt - Date.now() : 'unknown'
|
timeUntilExpiry: sessionData.expiresAt ? sessionData.expiresAt - Date.now() : 'unknown'
|
||||||
})
|
})
|
||||||
} catch (parseError) {
|
} catch (parseError) {
|
||||||
console.error('[SESSION] Failed to parse session cookie:', parseError)
|
console.error(`[SESSION:${requestId}] Failed to parse session cookie:`, parseError)
|
||||||
// Clear invalid session
|
// Clear invalid session
|
||||||
const cookieDomain = process.env.COOKIE_DOMAIN || '.portnimara.dev';
|
const cookieDomain = process.env.COOKIE_DOMAIN || '.portnimara.dev';
|
||||||
deleteCookie(event, 'nuxt-oidc-auth', {
|
deleteCookie(event, 'nuxt-oidc-auth', {
|
||||||
domain: cookieDomain,
|
domain: cookieDomain,
|
||||||
path: '/'
|
path: '/'
|
||||||
})
|
})
|
||||||
return { user: null, authenticated: false, groups: [] }
|
return {
|
||||||
|
user: null,
|
||||||
|
authenticated: false,
|
||||||
|
groups: [],
|
||||||
|
reason: 'INVALID_SESSION_FORMAT',
|
||||||
|
requestId
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate session structure
|
// Validate session structure
|
||||||
if (!sessionData.user || !sessionData.accessToken) {
|
if (!sessionData.user || !sessionData.accessToken) {
|
||||||
console.error('[SESSION] Invalid session structure:', {
|
console.error(`[SESSION:${requestId}] Invalid session structure:`, {
|
||||||
hasUser: !!sessionData.user,
|
hasUser: !!sessionData.user,
|
||||||
hasAccessToken: !!sessionData.accessToken
|
hasAccessToken: !!sessionData.accessToken
|
||||||
})
|
})
|
||||||
|
|
@ -46,12 +63,18 @@ export default defineEventHandler(async (event) => {
|
||||||
domain: cookieDomain,
|
domain: cookieDomain,
|
||||||
path: '/'
|
path: '/'
|
||||||
})
|
})
|
||||||
return { user: null, authenticated: false, groups: [] }
|
return {
|
||||||
|
user: null,
|
||||||
|
authenticated: false,
|
||||||
|
groups: [],
|
||||||
|
reason: 'INVALID_SESSION_STRUCTURE',
|
||||||
|
requestId
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if session is still valid
|
// Check if session is still valid
|
||||||
if (sessionData.expiresAt && Date.now() > sessionData.expiresAt) {
|
if (sessionData.expiresAt && Date.now() > sessionData.expiresAt) {
|
||||||
console.log('[SESSION] Session expired:', {
|
console.log(`[SESSION:${requestId}] Session expired:`, {
|
||||||
expiresAt: sessionData.expiresAt,
|
expiresAt: sessionData.expiresAt,
|
||||||
currentTime: Date.now(),
|
currentTime: Date.now(),
|
||||||
expiredSince: Date.now() - sessionData.expiresAt
|
expiredSince: Date.now() - sessionData.expiresAt
|
||||||
|
|
@ -62,7 +85,13 @@ export default defineEventHandler(async (event) => {
|
||||||
domain: cookieDomain,
|
domain: cookieDomain,
|
||||||
path: '/'
|
path: '/'
|
||||||
})
|
})
|
||||||
return { user: null, authenticated: false, groups: [] }
|
return {
|
||||||
|
user: null,
|
||||||
|
authenticated: false,
|
||||||
|
groups: [],
|
||||||
|
reason: 'SESSION_EXPIRED',
|
||||||
|
requestId
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract groups from ID token
|
// Extract groups from ID token
|
||||||
|
|
|
||||||
|
|
@ -184,21 +184,44 @@ class KeycloakClient {
|
||||||
|
|
||||||
const tokenUrl = 'https://auth.portnimara.dev/realms/client-portal/protocol/openid-connect/token'
|
const tokenUrl = 'https://auth.portnimara.dev/realms/client-portal/protocol/openid-connect/token'
|
||||||
|
|
||||||
return this.fetch(tokenUrl, {
|
try {
|
||||||
method: 'POST',
|
const response = await this.fetch(tokenUrl, {
|
||||||
headers: {
|
method: 'POST',
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
headers: {
|
||||||
},
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
body: new URLSearchParams({
|
},
|
||||||
grant_type: 'refresh_token',
|
body: new URLSearchParams({
|
||||||
client_id: 'client-portal',
|
grant_type: 'refresh_token',
|
||||||
client_secret: clientSecret,
|
client_id: 'client-portal',
|
||||||
refresh_token: refreshToken
|
client_secret: clientSecret,
|
||||||
}).toString()
|
refresh_token: refreshToken
|
||||||
}, {
|
}).toString()
|
||||||
timeout: 15000,
|
}, {
|
||||||
retries: 1 // Only 1 retry for refresh operations
|
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() {
|
getCircuitBreakerStatus() {
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue