using ControlPlane.Core.Config; using ControlPlane.Core.Interfaces; using ControlPlane.Core.Models; using ControlPlane.Worker.Services; using Microsoft.Extensions.Options; namespace ControlPlane.Worker.Steps; public class KeycloakStep( KeycloakAdminClient adminClient, IConfiguration config, IOptions infraOptions, ILogger logger) : ISagaStep { public string StepName => "Identity Bootstrapping (Keycloak)"; public async Task ExecuteAsync(SagaContext context, CancellationToken cancellationToken) { var realmId = RealmId(context); logger.LogInformation("[{JobId}] Creating Keycloak realm {Realm}.", context.Job.Id, realmId); await adminClient.CreateRealmAsync(realmId, context.Job.ClientName, cancellationToken); logger.LogInformation("[{JobId}] Creating AgencyAdmin role.", context.Job.Id); await adminClient.CreateRealmRoleAsync(realmId, "AgencyAdmin", "Day-zero administrator for this Clarity tenant.", cancellationToken); // Derive the tenant's public-facing origin from ClarityInfraOptions. var tenantOrigin = infraOptions.Value.TenantPublicUrl(context.Job.Subdomain); logger.LogInformation("[{JobId}] Creating Keycloak clients for realm {Realm} (origin: {Origin}).", context.Job.Id, realmId, tenantOrigin); // clarity-rest-api: bearer-only resource server — just registers the audience so JWT validation passes. await adminClient.CreateClientAsync(realmId, new { clientId = "clarity-rest-api", name = "Clarity REST API", enabled = true, bearerOnly = true, publicClient = false, }, cancellationToken); // clarity-web-app: public OIDC client used by the React frontend. // fdev is a developer dogfood environment — allow localhost redirect URIs so that a // local Aspire dev loop (any port) can complete the OIDC flow against the shared // OPC infra Keycloak without any post-provisioning patching. var isFdev = string.Equals(context.Job.Environment, "fdev", StringComparison.OrdinalIgnoreCase); var redirectUris = isFdev ? new[] { $"{tenantOrigin}/*", "http://localhost:*/*", "http://*.dev.localhost:*/*" } : new[] { $"{tenantOrigin}/*" }; var webOrigins = isFdev ? "+" // match all valid redirect URI origins : tenantOrigin; await adminClient.CreateClientAsync(realmId, new { clientId = "clarity-web-app", name = "Clarity Web App", enabled = true, publicClient = true, standardFlowEnabled = true, directAccessGrantsEnabled = false, rootUrl = tenantOrigin, baseUrl = "/", redirectUris, webOrigins = new[] { webOrigins }, }, cancellationToken); // Ensure tokens issued by clarity-web-app include "clarity-rest-api" in the `aud` claim // so that Clarity.Server JWT bearer validation (Audience = "clarity-rest-api") passes. logger.LogInformation("[{JobId}] Adding audience mapper for clarity-rest-api on clarity-web-app.", context.Job.Id); var webAppUuid = await adminClient.GetClientUuidAsync(realmId, "clarity-web-app", cancellationToken); await adminClient.AddAudienceMapperAsync(realmId, webAppUuid, "clarity-rest-api", cancellationToken); logger.LogInformation("[{JobId}] Creating day-zero user {Email}.", context.Job.Id, context.Job.AdminEmail); var userId = await adminClient.CreateUserAsync(realmId, context.Job.AdminEmail, context.Job.ClientName, cancellationToken); logger.LogInformation("[{JobId}] Assigning AgencyAdmin role.", context.Job.Id); await adminClient.AssignRealmRoleAsync(realmId, userId, "AgencyAdmin", cancellationToken); // TODO No SMTP right now //logger.LogInformation("[{JobId}] Sending required actions email to {Email}.", context.Job.Id, context.Job.AdminEmail); //await adminClient.SendRequiredActionsEmailAsync(realmId, userId, ["UPDATE_PASSWORD", "VERIFY_EMAIL"], cancellationToken); context.DayZeroUserSubjectId = userId; //context.MagicLink = $"Action email sent to {context.Job.AdminEmail} for realm '{realmId}'."; context.Job.CompletedSteps |= CompletedSteps.KeycloakProvisioned; logger.LogInformation("[{JobId}] Keycloak provisioning complete for realm {Realm}.", context.Job.Id, realmId); } public async Task CompensateAsync(SagaContext context, CancellationToken cancellationToken) { var realmId = RealmId(context); logger.LogWarning("[{JobId}] Compensating Keycloak - deleting realm {Realm}.", context.Job.Id, realmId); await adminClient.DeleteRealmAsync(realmId, cancellationToken); } private static string RealmId(SagaContext context) => $"clarity-{context.Job.Subdomain.ToLowerInvariant()}"; }