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

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