243 lines
7.3 KiB
TypeScript
243 lines
7.3 KiB
TypeScript
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<any> {
|
|
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<any> {
|
|
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<any> {
|
|
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<any> {
|
|
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'
|
|
|
|
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() {
|
|
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)
|
|
}
|