interface KeycloakClientOptions { timeout?: number retries?: number retryDelay?: number } interface CircuitBreakerState { failures: number lastFailure: number isOpen: boolean } // Simple connection pool and circuit breaker for Keycloak requests class KeycloakClient { private static instance: KeycloakClient private circuitBreaker: CircuitBreakerState = { failures: 0, lastFailure: 0, isOpen: false } private readonly maxFailures = 5 private readonly resetTimeout = 60000 // 1 minute private constructor() {} static getInstance(): KeycloakClient { if (!KeycloakClient.instance) { KeycloakClient.instance = new KeycloakClient() } return KeycloakClient.instance } private isCircuitOpen(): boolean { if (this.circuitBreaker.isOpen) { // Check if we should reset the circuit breaker if (Date.now() - this.circuitBreaker.lastFailure > this.resetTimeout) { console.log('[KEYCLOAK_CLIENT] Circuit breaker reset - attempting requests') this.circuitBreaker.isOpen = false this.circuitBreaker.failures = 0 } } return this.circuitBreaker.isOpen } private recordFailure(): void { this.circuitBreaker.failures++ this.circuitBreaker.lastFailure = Date.now() if (this.circuitBreaker.failures >= this.maxFailures) { console.error(`[KEYCLOAK_CLIENT] Circuit breaker OPEN - too many failures (${this.circuitBreaker.failures})`) this.circuitBreaker.isOpen = true } } private recordSuccess(): void { if (this.circuitBreaker.failures > 0) { console.log('[KEYCLOAK_CLIENT] Request successful - resetting failure count') this.circuitBreaker.failures = 0 } } async fetch(url: string, options: any = {}, clientOptions: KeycloakClientOptions = {}): Promise { const { retries = 3, retryDelay = 1000 } = clientOptions // Check circuit breaker if (this.isCircuitOpen()) { throw createError({ statusCode: 503, statusMessage: 'Keycloak service temporarily unavailable (circuit breaker open)' }) } const startTime = Date.now() let lastError: any for (let attempt = 1; attempt <= retries + 1; attempt++) { try { console.log(`[KEYCLOAK_CLIENT] Attempt ${attempt}/${retries + 1} for ${url}`) // Use basic $fetch without problematic options const response = await $fetch(url, { ...options, // Don't add custom headers that might be incompatible retry: 0 // Disable automatic retries from $fetch to handle them ourselves }) const duration = Date.now() - startTime console.log(`[KEYCLOAK_CLIENT] Request successful in ${duration}ms`) this.recordSuccess() return response } catch (error: any) { lastError = error const duration = Date.now() - startTime console.error(`[KEYCLOAK_CLIENT] Attempt ${attempt} failed after ${duration}ms:`, { status: error.status, message: error.message, url: url.replace(/client_secret=[^&]*/g, 'client_secret=***') }) // Don't retry on client errors (4xx) if (error.status >= 400 && error.status < 500) { console.log('[KEYCLOAK_CLIENT] Client error - no retry') this.recordFailure() throw error } // Don't retry on the last attempt if (attempt === retries + 1) { console.error('[KEYCLOAK_CLIENT] All retry attempts exhausted') this.recordFailure() break } // Exponential backoff delay const delay = retryDelay * Math.pow(2, attempt - 1) console.log(`[KEYCLOAK_CLIENT] Waiting ${delay}ms before retry...`) await new Promise(resolve => setTimeout(resolve, delay)) } } this.recordFailure() throw lastError || createError({ statusCode: 502, statusMessage: 'Failed to connect to Keycloak after multiple attempts' }) } async exchangeCodeForTokens(code: string, redirectUri: string): Promise { const clientSecret = process.env.KEYCLOAK_CLIENT_SECRET if (!clientSecret) { throw createError({ statusCode: 500, statusMessage: 'KEYCLOAK_CLIENT_SECRET not configured' }) } 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: 'authorization_code', client_id: 'client-portal', client_secret: clientSecret, code: code, redirect_uri: redirectUri }).toString() }, { timeout: 20000, // 20 second timeout for token exchange retries: 2 // Only 2 retries for auth operations }) } async getUserInfo(accessToken: string): Promise { const userInfoUrl = 'https://auth.portnimara.dev/realms/client-portal/protocol/openid-connect/userinfo' return this.fetch(userInfoUrl, { headers: { 'Authorization': `Bearer ${accessToken}` } }, { timeout: 15000, // 15 second timeout for user info retries: 2 }) } async refreshAccessToken(refreshToken: string): Promise { const clientSecret = process.env.KEYCLOAK_CLIENT_SECRET if (!clientSecret) { throw createError({ statusCode: 500, statusMessage: 'KEYCLOAK_CLIENT_SECRET not configured' }) } 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 }) } getCircuitBreakerStatus() { return { isOpen: this.circuitBreaker.isOpen, failures: this.circuitBreaker.failures, lastFailure: this.circuitBreaker.lastFailure ? new Date(this.circuitBreaker.lastFailure).toISOString() : null } } } // Export singleton instance export const keycloakClient = KeycloakClient.getInstance() // Helper function for backward compatibility export const keycloakFetch = (url: string, options: any = {}, clientOptions: KeycloakClientOptions = {}) => { return keycloakClient.fetch(url, options, clientOptions) }