273 lines
7.8 KiB
TypeScript
273 lines
7.8 KiB
TypeScript
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 }
|