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:
@@ -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() {
|
||||
|
||||
272
server/utils/session-manager.ts
Normal file
272
server/utils/session-manager.ts
Normal 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 }
|
||||
Reference in New Issue
Block a user