package dev.lions.user.manager.client; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; import io.quarkus.runtime.Startup; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import lombok.extern.slf4j.Slf4j; import org.eclipse.microprofile.config.inject.ConfigProperty; import org.eclipse.microprofile.faulttolerance.CircuitBreaker; import org.eclipse.microprofile.faulttolerance.Retry; import org.eclipse.microprofile.faulttolerance.Timeout; import org.keycloak.admin.client.Keycloak; import org.keycloak.admin.client.resource.RealmResource; import org.keycloak.admin.client.resource.RolesResource; import org.keycloak.admin.client.resource.UsersResource; import jakarta.ws.rs.NotFoundException; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.temporal.ChronoUnit; import java.util.List; import java.util.Map; import java.util.stream.Collectors; /** * Implémentation du client Keycloak Admin * Utilise le bean Keycloak géré par Quarkus (quarkus-keycloak-admin-rest-client) * qui respecte la configuration Jackson (fail-on-unknown-properties=false) * Utilise Circuit Breaker, Retry et Timeout pour la résilience */ @ApplicationScoped @Startup @Slf4j public class KeycloakAdminClientImpl implements KeycloakAdminClient { @Inject Keycloak keycloak; @ConfigProperty(name = "lions.keycloak.server-url") String serverUrl; @ConfigProperty(name = "lions.keycloak.admin-realm") String adminRealm; @ConfigProperty(name = "lions.keycloak.admin-client-id") String adminClientId; @ConfigProperty(name = "lions.keycloak.admin-username") String adminUsername; @PostConstruct void init() { log.info("========================================"); log.info("Initialisation du client Keycloak Admin"); log.info("========================================"); log.info("Server URL: {}", serverUrl); log.info("Admin Realm: {}", adminRealm); log.info("Admin Client ID: {}", adminClientId); log.info("Admin Username: {}", adminUsername); log.info("✅ Client Keycloak initialisé via Quarkus CDI (connexion lazy)"); log.info("La connexion sera établie lors de la première requête API"); } @Override @Retry(maxRetries = 3, delay = 2, delayUnit = ChronoUnit.SECONDS) @Timeout(value = 30, unit = ChronoUnit.SECONDS) @CircuitBreaker(requestVolumeThreshold = 10, failureRatio = 0.5, delay = 5000) public Keycloak getInstance() { return keycloak; } @Override @Retry(maxRetries = 3, delay = 2, delayUnit = ChronoUnit.SECONDS) @Timeout(value = 30, unit = ChronoUnit.SECONDS) @CircuitBreaker(requestVolumeThreshold = 10, failureRatio = 0.5, delay = 5000) public RealmResource getRealm(String realmName) { try { return keycloak.realm(realmName); } catch (Exception e) { log.error("Erreur lors de la récupération du realm {}: {}", realmName, e.getMessage()); throw new RuntimeException("Impossible de récupérer le realm: " + realmName, e); } } @Override @Retry(maxRetries = 3, delay = 2, delayUnit = ChronoUnit.SECONDS) @Timeout(value = 30, unit = ChronoUnit.SECONDS) @CircuitBreaker(requestVolumeThreshold = 10, failureRatio = 0.5, delay = 5000) public UsersResource getUsers(String realmName) { return getRealm(realmName).users(); } @Override @Retry(maxRetries = 3, delay = 2, delayUnit = ChronoUnit.SECONDS) @Timeout(value = 30, unit = ChronoUnit.SECONDS) @CircuitBreaker(requestVolumeThreshold = 10, failureRatio = 0.5, delay = 5000) public RolesResource getRoles(String realmName) { return getRealm(realmName).roles(); } @Override public boolean isConnected() { try { // getAccessTokenString() n'implique pas la désérialisation de ServerInfoRepresentation // (qui échoue sur le champ inconnu "cpuInfo" avec Keycloak 26+) keycloak.tokenManager().getAccessTokenString(); return true; } catch (Exception e) { log.warn("Keycloak non connecté: {}", e.getMessage()); return false; } } @Override public boolean realmExists(String realmName) { try { getRealm(realmName).roles().list(); return true; } catch (NotFoundException e) { log.debug("Realm {} n'existe pas", realmName); return false; } catch (Exception e) { log.debug("Erreur lors de la vérification du realm {} (probablement il existe): {}", realmName, e.getMessage()); return true; } } @Override @Retry(maxRetries = 3, delay = 2, delayUnit = ChronoUnit.SECONDS) @Timeout(value = 30, unit = ChronoUnit.SECONDS) @CircuitBreaker(requestVolumeThreshold = 10, failureRatio = 0.5, delay = 5000) public List getAllRealms() { try { log.debug("Récupération de tous les realms depuis Keycloak"); // Appel HTTP direct pour éviter l'erreur de désérialisation de RealmRepresentation // (champ bruteForceStrategy inconnu dans la version de la librairie cliente) String token = keycloak.tokenManager().getAccessTokenString(); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(serverUrl + "/admin/realms")) .header("Authorization", "Bearer " + token) .header("Accept", "application/json") .GET() .build(); HttpResponse response = HttpClient.newHttpClient() .send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() != 200) { throw new RuntimeException("Keycloak returned HTTP " + response.statusCode()); } ObjectMapper mapper = new ObjectMapper() .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); List> realmMaps = mapper.readValue( response.body(), new TypeReference<>() {}); List realms = realmMaps.stream() .map(r -> (String) r.get("realm")) .filter(r -> r != null) .collect(Collectors.toList()); log.debug("Realms récupérés: {}", realms); return realms; } catch (Exception e) { log.error("Erreur lors de la récupération de tous les realms: {}", e.getMessage()); throw new RuntimeException("Impossible de récupérer la liste des realms", e); } } @Override @Retry(maxRetries = 3, delay = 2, delayUnit = ChronoUnit.SECONDS) @Timeout(value = 30, unit = ChronoUnit.SECONDS) @CircuitBreaker(requestVolumeThreshold = 10, failureRatio = 0.5, delay = 5000) public List getRealmClients(String realmName) { try { log.debug("Récupération des clients du realm {}", realmName); String token = keycloak.tokenManager().getAccessTokenString(); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(serverUrl + "/admin/realms/" + realmName + "/clients")) .header("Authorization", "Bearer " + token) .header("Accept", "application/json") .GET() .build(); HttpResponse response = HttpClient.newHttpClient() .send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() != 200) { throw new RuntimeException("Keycloak returned HTTP " + response.statusCode()); } ObjectMapper mapper = new ObjectMapper() .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); List> clientMaps = mapper.readValue( response.body(), new TypeReference<>() {}); List clients = clientMaps.stream() .map(c -> (String) c.get("clientId")) .filter(c -> c != null) .collect(Collectors.toList()); log.debug("Clients récupérés pour {}: {}", realmName, clients); return clients; } catch (Exception e) { log.error("Erreur lors de la récupération des clients du realm {}: {}", realmName, e.getMessage()); throw new RuntimeException("Impossible de récupérer les clients du realm: " + realmName, e); } } @PreDestroy @Override public void close() { log.info("Fermeture de la connexion Keycloak..."); // Le cycle de vie est géré par Quarkus CDI } @Override public void reconnect() { log.info("Reconnexion à Keycloak... (géré par Quarkus CDI)"); // Le bean Keycloak est géré par Quarkus, pas de reconnexion manuelle nécessaire } }