Files
OPC/ControlPlane.Worker/Services/KeycloakAdminClient.cs
T

216 lines
9.2 KiB
C#

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<KeycloakAdminClient> _logger;
private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web);
public KeycloakAdminClient(IConfiguration config, ILogger<KeycloakAdminClient> 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) };
}
/// <summary>
/// Creates a KeycloakAdminClient for a specific base URL and credentials.
/// Used by KeycloakStep to target SharedPlatform or OwnContainer Keycloak instances
/// using the resolved topology rather than static DI configuration.
/// </summary>
public static KeycloakAdminClient ForUrl(
string adminUrl, string adminUser, string adminPassword,
ILogger<KeycloakAdminClient> logger)
=> new(adminUrl, adminUser, adminPassword, logger);
private KeycloakAdminClient(
string adminUrl, string adminUser, string adminPassword,
ILogger<KeycloakAdminClient> logger)
{
_logger = logger;
_baseUrl = adminUrl.TrimEnd('/');
_adminUser = adminUser;
_adminPassword = adminPassword;
_logger.LogInformation("KeycloakAdminClient base URL: {Url}, user: {User}", _baseUrl, _adminUser);
_http = new HttpClient { BaseAddress = new Uri(_baseUrl) };
}
private async Task AuthorizeAsync(CancellationToken ct)
{
var form = new FormUrlEncodedContent(new Dictionary<string, string>
{
["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<string> 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<string> 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();
}
/// <summary>
/// Returns the internal Keycloak UUID for a client by its clientId string.
/// </summary>
public async Task<string> 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()!;
}
/// <summary>
/// Adds an audience protocol mapper to a client so that the named audience is included in every
/// access token issued by that client.
/// </summary>
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<string, string>
{
["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<string> 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");
}