194 lines
8.3 KiB
C#
194 lines
8.3 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) };
|
|
}
|
|
|
|
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");
|
|
} |