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:
2025-07-11 14:43:50 -04:00
parent bf2361050f
commit c6f81a6686
8 changed files with 1051 additions and 139 deletions

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 }