FEAT: Implement Keycloak client with circuit breaker and retry logic for improved authentication resilience
This commit is contained in:
@@ -1,65 +1,66 @@
|
||||
import { keycloakClient } from '~/server/utils/keycloak-client'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const startTime = Date.now()
|
||||
const query = getQuery(event)
|
||||
const { code, state, error } = query
|
||||
|
||||
console.log('[KEYCLOAK] Callback received:', { code: !!code, state, error })
|
||||
console.log('[KEYCLOAK] Callback received:', {
|
||||
code: !!code,
|
||||
state,
|
||||
error,
|
||||
requestId: event.node.req.headers['x-request-id'] || 'unknown'
|
||||
})
|
||||
|
||||
if (error) {
|
||||
console.error('[KEYCLOAK] OAuth error:', error)
|
||||
const errorMsg = `Authentication failed: ${error}`
|
||||
console.error('[KEYCLOAK] OAuth error:', errorMsg)
|
||||
|
||||
// Add timing info for debugging
|
||||
const duration = Date.now() - startTime
|
||||
console.error(`[KEYCLOAK] Failed after ${duration}ms`)
|
||||
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: `Authentication failed: ${error}`
|
||||
statusMessage: errorMsg
|
||||
})
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
console.error('[KEYCLOAK] No authorization code received')
|
||||
const errorMsg = 'No authorization code received'
|
||||
console.error('[KEYCLOAK] ' + errorMsg)
|
||||
|
||||
const duration = Date.now() - startTime
|
||||
console.error(`[KEYCLOAK] Failed after ${duration}ms`)
|
||||
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'No authorization code received'
|
||||
statusMessage: errorMsg
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
// Validate environment variables
|
||||
const clientSecret = process.env.KEYCLOAK_CLIENT_SECRET
|
||||
if (!clientSecret) {
|
||||
console.error('[KEYCLOAK] KEYCLOAK_CLIENT_SECRET not configured')
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Authentication service misconfigured'
|
||||
})
|
||||
}
|
||||
|
||||
// Exchange authorization code for tokens
|
||||
const tokenResponse = await $fetch('https://auth.portnimara.dev/realms/client-portal/protocol/openid-connect/token', {
|
||||
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 as string,
|
||||
redirect_uri: 'https://client.portnimara.dev/api/auth/keycloak/callback'
|
||||
}).toString()
|
||||
}) as any
|
||||
|
||||
console.log('[KEYCLOAK] Token exchange successful:', {
|
||||
console.log('[KEYCLOAK] Starting token exchange...')
|
||||
const redirectUri = 'https://client.portnimara.dev/api/auth/keycloak/callback'
|
||||
|
||||
// Use the new Keycloak client with retry logic and circuit breaker
|
||||
const tokenResponse = await keycloakClient.exchangeCodeForTokens(code as string, redirectUri)
|
||||
|
||||
const tokenExchangeDuration = Date.now() - startTime
|
||||
console.log(`[KEYCLOAK] Token exchange successful in ${tokenExchangeDuration}ms:`, {
|
||||
hasAccessToken: !!tokenResponse.access_token,
|
||||
hasRefreshToken: !!tokenResponse.refresh_token,
|
||||
expiresIn: tokenResponse.expires_in
|
||||
})
|
||||
|
||||
// Get user info
|
||||
const userInfo = await $fetch('https://auth.portnimara.dev/realms/client-portal/protocol/openid-connect/userinfo', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${tokenResponse.access_token}`
|
||||
}
|
||||
}) as any
|
||||
|
||||
console.log('[KEYCLOAK] User info retrieved:', {
|
||||
// Get user info with retry logic
|
||||
console.log('[KEYCLOAK] Fetching user info...')
|
||||
const userInfoStartTime = Date.now()
|
||||
|
||||
const userInfo = await keycloakClient.getUserInfo(tokenResponse.access_token)
|
||||
|
||||
const userInfoDuration = Date.now() - userInfoStartTime
|
||||
console.log(`[KEYCLOAK] User info retrieved in ${userInfoDuration}ms:`, {
|
||||
sub: userInfo.sub,
|
||||
email: userInfo.email,
|
||||
username: userInfo.preferred_username,
|
||||
@@ -95,21 +96,39 @@ export default defineEventHandler(async (event) => {
|
||||
path: '/'
|
||||
})
|
||||
|
||||
console.log('[KEYCLOAK] Session cookie set successfully')
|
||||
console.log('[KEYCLOAK] Redirecting to dashboard...')
|
||||
const totalDuration = Date.now() - startTime
|
||||
console.log(`[KEYCLOAK] Authentication completed successfully in ${totalDuration}ms`)
|
||||
console.log('[KEYCLOAK] Session cookie set, redirecting to dashboard...')
|
||||
|
||||
// Redirect to dashboard
|
||||
await sendRedirect(event, '/dashboard')
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('[KEYCLOAK] Token exchange failed:', error)
|
||||
console.error('[KEYCLOAK] Error details:', {
|
||||
const duration = Date.now() - startTime
|
||||
console.error(`[KEYCLOAK] Authentication failed after ${duration}ms:`, {
|
||||
message: error.message,
|
||||
status: error.status,
|
||||
statusMessage: error.statusMessage,
|
||||
data: error.data
|
||||
})
|
||||
|
||||
// Redirect to login with error
|
||||
await sendRedirect(event, '/login?error=auth_failed')
|
||||
// Log circuit breaker status for debugging
|
||||
const circuitStatus = keycloakClient.getCircuitBreakerStatus()
|
||||
if (circuitStatus.isOpen) {
|
||||
console.error('[KEYCLOAK] Circuit breaker is OPEN:', circuitStatus)
|
||||
}
|
||||
|
||||
// Provide more specific error messages
|
||||
let errorParam = 'auth_failed'
|
||||
if (error.status === 503) {
|
||||
errorParam = 'service_unavailable'
|
||||
} else if (error.status >= 500) {
|
||||
errorParam = 'server_error'
|
||||
} else if (error.status === 401 || error.status === 403) {
|
||||
errorParam = 'access_denied'
|
||||
}
|
||||
|
||||
// Redirect to login with specific error
|
||||
await sendRedirect(event, `/login?error=${errorParam}`)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { keycloakClient } from '~/server/utils/keycloak-client'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const startTime = Date.now()
|
||||
console.log('[REFRESH] Processing token refresh request')
|
||||
|
||||
try {
|
||||
@@ -43,21 +46,12 @@ export default defineEventHandler(async (event) => {
|
||||
})
|
||||
}
|
||||
|
||||
// Use refresh token to get new access token
|
||||
const tokenResponse = await $fetch('https://auth.portnimara.dev/realms/client-portal/protocol/openid-connect/token', {
|
||||
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: sessionData.refreshToken
|
||||
}).toString()
|
||||
}) as any
|
||||
// Use refresh token to get new access token with retry logic
|
||||
console.log('[REFRESH] Using Keycloak client for token refresh...')
|
||||
const tokenResponse = await keycloakClient.refreshAccessToken(sessionData.refreshToken)
|
||||
|
||||
console.log('[REFRESH] Token refresh successful:', {
|
||||
const refreshDuration = Date.now() - startTime
|
||||
console.log(`[REFRESH] Token refresh successful in ${refreshDuration}ms:`, {
|
||||
hasAccessToken: !!tokenResponse.access_token,
|
||||
hasRefreshToken: !!tokenResponse.refresh_token,
|
||||
expiresIn: tokenResponse.expires_in
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import { getAppReadiness } from '~/plugins/00.startup-check.server'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const readiness = getAppReadiness()
|
||||
|
||||
return {
|
||||
status: 'healthy',
|
||||
status: readiness.ready ? 'healthy' : 'starting',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime(),
|
||||
environment: process.env.NODE_ENV || 'development',
|
||||
oidc: {
|
||||
configured: !!process.env.NUXT_OIDC_TOKEN_KEY,
|
||||
hasClientSecret: !!process.env.NUXT_OIDC_PROVIDERS_KEYCLOAK_CLIENT_SECRET
|
||||
readiness: {
|
||||
ready: readiness.ready,
|
||||
keycloakCircuitBreaker: readiness.keycloakCircuitBreaker
|
||||
},
|
||||
auth: {
|
||||
configured: !!process.env.KEYCLOAK_CLIENT_SECRET,
|
||||
cookieDomain: process.env.COOKIE_DOMAIN || '.portnimara.dev'
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
226
server/utils/keycloak-client.ts
Normal file
226
server/utils/keycloak-client.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
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 {
|
||||
timeout = 30000,
|
||||
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}`)
|
||||
|
||||
const response = await $fetch(url, {
|
||||
...options,
|
||||
timeout,
|
||||
// Add connection reuse headers
|
||||
headers: {
|
||||
...options.headers,
|
||||
'Connection': 'keep-alive',
|
||||
'Keep-Alive': 'timeout=30, max=100'
|
||||
},
|
||||
// Disable automatic retries from $fetch to handle them ourselves
|
||||
retry: 0
|
||||
})
|
||||
|
||||
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'
|
||||
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user