using System.Net.Http.Json; using GB5Shared.Vault; using Microsoft.Extensions.Logging; namespace EntitlementBLL.Onboarding; public class KeycloakProvisioningClient : IKeycloakProvisioningClient { // Same Vault path FrameworkSL's KeycloakApiKeyAuthMiddleware validates against — GB5's own // internal M2M shared secret protecting the /Keycloak/* admin-API surface, distinct from // Keycloak's own admin-cli credential. private const string ApiKeyVaultPath = "keycloak-admin-api/shared-secret"; private readonly IHttpClientFactory _HttpClientFactory; private readonly IVaultService _VaultService; private readonly ILogger _Logger; public KeycloakProvisioningClient( IHttpClientFactory httpClientFactory, IVaultService vaultService, ILogger logger) { _HttpClientFactory = httpClientFactory; _VaultService = vaultService; _Logger = logger; } public async Task ProvisionRealmAndClientAsync( string realmName, string clientId, string clientName, string redirectUriPattern, CancellationToken ct) { // Reuses the "FrameworkDataSync" named client — FrameworkSL is the same service either // way (ReferenceDataSyncClient's own comment already establishes this base URL points // there); no reason for Keycloak provisioning to need its own separate config entry. var client = _HttpClientFactory.CreateClient("FrameworkDataSync"); string apiKey = await _VaultService.GetSecretAsync(ApiKeyVaultPath, ct).ConfigureAwait(false); client.DefaultRequestHeaders.Remove("X-Api-Key"); client.DefaultRequestHeaders.Add("X-Api-Key", apiKey); // FrameworkSL/Endpoints/KeyCloak/CreateRealm.cs takes no Login header at all (its request // record has no Login property — BaseEndPoint's reflection-based lookup just returns null, // which the endpoint handles fine) — same for CreateClient below. var realmResponse = await client.GetAsync($"/Keycloak/CreateRealm?RealmName={Uri.EscapeDataString(realmName)}", ct) .ConfigureAwait(false); var realmRaw = await realmResponse.Content.ReadAsStringAsync(ct).ConfigureAwait(false); if (!realmResponse.IsSuccessStatusCode) { _Logger.LogError("CreateRealm({RealmName}) returned {StatusCode}: {Body}", realmName, realmResponse.StatusCode, realmRaw); throw new InvalidOperationException($"CreateRealm({realmName}) failed ({(int)realmResponse.StatusCode}): {realmRaw}"); } // [property: FromBody] on a Parameters record binds the raw top-level body — never wrap // it in an outer object named after the property (a known FastEndpoints gotcha in this // repo's own conventions). var clientPayload = new { ClientId = clientId, ClientName = clientName, RealmName = realmName, PublicClient = "false", ClientEnabled = "true", RedirectUris = redirectUriPattern, }; var clientResponse = await client.PostAsJsonAsync("/Keycloak/CreateClient", clientPayload, ct).ConfigureAwait(false); var clientRaw = await clientResponse.Content.ReadAsStringAsync(ct).ConfigureAwait(false); if (!clientResponse.IsSuccessStatusCode) { _Logger.LogError("CreateClient({ClientId}, realm {RealmName}) returned {StatusCode}: {Body}", clientId, realmName, clientResponse.StatusCode, clientRaw); throw new InvalidOperationException( $"CreateClient({clientId}, realm {realmName}) failed ({(int)clientResponse.StatusCode}): {clientRaw}"); } _Logger.LogInformation("Keycloak realm '{RealmName}' + client '{ClientId}' provisioned successfully.", realmName, clientId); } }