OPC # 0001: Extract OPC into standalone repo
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
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");
|
||||
}
|
||||
Reference in New Issue
Block a user