using System.Net.Http.Headers; using System.Text; using System.Text.Json; namespace ControlPlane.Worker.Services; public class KeycloakAdminClient { private readonly HttpClient _http; private readonly string _baseUrl; private readonly string _adminUser; private readonly string _adminPassword; private readonly ILogger _logger; private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web); public KeycloakAdminClient(IConfiguration config, ILogger logger) { _logger = logger; _baseUrl = (config["Keycloak:AuthServerUrl"] ?? config["Keycloak:BaseUrl"])?.TrimEnd('/') ?? "http://localhost:8080"; _adminUser = config["Keycloak:AdminUser"] ?? "admin"; _adminPassword = config["Keycloak:AdminPassword"] ?? "admin"; var maskedPw = _adminPassword.Length > 2 ? $"{_adminPassword[0]}***{_adminPassword[^1]}" : "***"; _logger.LogInformation("KeycloakAdminClient base URL: {Url}, user: {User}, password: {Password}", _baseUrl, _adminUser, maskedPw); _http = new HttpClient { BaseAddress = new Uri(_baseUrl) }; } private async Task AuthorizeAsync(CancellationToken ct) { var form = new FormUrlEncodedContent(new Dictionary { ["grant_type"] = "password", ["client_id"] = "admin-cli", ["username"] = _adminUser, ["password"] = _adminPassword, }); var res = await _http.PostAsync("/realms/master/protocol/openid-connect/token", form, ct); res.EnsureSuccessStatusCode(); using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync(ct)); var token = doc.RootElement.GetProperty("access_token").GetString()!; _http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); } public async Task CreateRealmAsync(string realmId, string displayName, CancellationToken ct) { await AuthorizeAsync(ct); var res = await _http.PostAsync("/admin/realms", Json(new { realm = realmId, displayName = displayName, enabled = true, registrationAllowed = true, registrationEmailAsUsername = false, loginWithEmailAllowed = true, resetPasswordAllowed = true, verifyEmail = false, sslRequired = "external", }), ct); if (res.StatusCode == System.Net.HttpStatusCode.Conflict) { _logger.LogWarning("Realm {Realm} already exists - skipping.", realmId); return; } res.EnsureSuccessStatusCode(); _logger.LogInformation("Realm {Realm} created.", realmId); } public async Task DeleteRealmAsync(string realmId, CancellationToken ct) { await AuthorizeAsync(ct); var res = await _http.DeleteAsync($"/admin/realms/{realmId}", ct); if (res.StatusCode != System.Net.HttpStatusCode.NotFound) res.EnsureSuccessStatusCode(); _logger.LogInformation("Realm {Realm} deleted.", realmId); } public async Task CreateRealmRoleAsync(string realmId, string roleName, string description, CancellationToken ct) { await AuthorizeAsync(ct); var res = await _http.PostAsync($"/admin/realms/{realmId}/roles", Json(new { name = roleName, description }), ct); if (res.StatusCode != System.Net.HttpStatusCode.Conflict) res.EnsureSuccessStatusCode(); } public async Task CreateUserAsync(string realmId, string email, string firstName, CancellationToken ct) { await AuthorizeAsync(ct); var res = await _http.PostAsync($"/admin/realms/{realmId}/users", Json(new { username = email, email, firstName, enabled = true, emailVerified = true }), ct); if (res.StatusCode != System.Net.HttpStatusCode.Conflict) res.EnsureSuccessStatusCode(); return await GetUserIdAsync(realmId, email, ct); } public async Task GetUserIdAsync(string realmId, string email, CancellationToken ct) { await AuthorizeAsync(ct); var res = await _http.GetAsync( $"/admin/realms/{realmId}/users?email={Uri.EscapeDataString(email)}&exact=true", ct); res.EnsureSuccessStatusCode(); using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync(ct)); var users = doc.RootElement.EnumerateArray().ToList(); if (users.Count == 0) throw new InvalidOperationException($"User {email} not found in realm {realmId}."); return users[0].GetProperty("id").GetString()!; } public async Task AssignRealmRoleAsync(string realmId, string userId, string roleName, CancellationToken ct) { await AuthorizeAsync(ct); var roleRes = await _http.GetAsync($"/admin/realms/{realmId}/roles/{roleName}", ct); roleRes.EnsureSuccessStatusCode(); var roleJson = await roleRes.Content.ReadAsStringAsync(ct); var res = await _http.PostAsync( $"/admin/realms/{realmId}/users/{userId}/role-mappings/realm", new StringContent($"[{roleJson}]", Encoding.UTF8, "application/json"), ct); res.EnsureSuccessStatusCode(); } public async Task CreateClientAsync(string realmId, object clientRepresentation, CancellationToken ct) { await AuthorizeAsync(ct); var res = await _http.PostAsync($"/admin/realms/{realmId}/clients", Json(clientRepresentation), ct); if (res.StatusCode != System.Net.HttpStatusCode.Conflict) res.EnsureSuccessStatusCode(); } /// /// Returns the internal Keycloak UUID for a client by its clientId string. /// public async Task GetClientUuidAsync(string realmId, string clientId, CancellationToken ct) { await AuthorizeAsync(ct); var res = await _http.GetAsync( $"/admin/realms/{realmId}/clients?clientId={Uri.EscapeDataString(clientId)}&search=false", ct); res.EnsureSuccessStatusCode(); using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync(ct)); var clients = doc.RootElement.EnumerateArray().ToList(); if (clients.Count == 0) throw new InvalidOperationException($"Client '{clientId}' not found in realm '{realmId}'."); return clients[0].GetProperty("id").GetString()!; } /// /// Adds an audience protocol mapper to a client so that the named audience is included in every /// access token issued by that client. /// public async Task AddAudienceMapperAsync(string realmId, string clientUuid, string audienceName, CancellationToken ct) { await AuthorizeAsync(ct); var res = await _http.PostAsync( $"/admin/realms/{realmId}/clients/{clientUuid}/protocol-mappers/models", Json(new { name = $"audience-{audienceName}", protocol = "openid-connect", protocolMapper = "oidc-audience-mapper", consentRequired = false, config = new Dictionary { ["included.client.audience"] = audienceName, ["id.token.claim"] = "false", ["access.token.claim"] = "true", }, }), ct); if (res.StatusCode != System.Net.HttpStatusCode.Conflict) res.EnsureSuccessStatusCode(); _logger.LogInformation("Added audience mapper '{Audience}' to client {ClientUuid} in realm {Realm}.", audienceName, clientUuid, realmId); } public async Task SendRequiredActionsEmailAsync( string realmId, string userId, IEnumerable actions, CancellationToken ct) { await AuthorizeAsync(ct); var res = await _http.PutAsync( $"/admin/realms/{realmId}/users/{userId}/execute-actions-email?lifespan=86400", new StringContent(JsonSerializer.Serialize(actions, JsonOpts), Encoding.UTF8, "application/json"), ct); res.EnsureSuccessStatusCode(); } private static StringContent Json(object payload) => new(JsonSerializer.Serialize(payload, JsonOpts), Encoding.UTF8, "application/json"); }