using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Serialization; using System.Xml.Linq; using Microsoft.Extensions.Logging; using OTSSignsOrchestrator.Core.Models.DTOs; namespace OTSSignsOrchestrator.Core.Services; /// /// Provisions SAML applications in Authentik via its REST API and retrieves /// IdP metadata (entity ID, signing certificate, SSO/SLO URLs). /// /// Workflow per instance: /// 1. Resolve the authorization + invalidation flow UUIDs (cached or from settings). /// 2. Create a SAML provider (POST /api/v3/providers/saml/). /// 3. Create an Authentik application linked to that provider (POST /api/v3/core/applications/). /// 4. Fetch SAML metadata XML (GET /api/v3/providers/saml/{id}/metadata/). /// 5. Parse XML to extract entityId, x509cert, SSO/SLO URLs. /// public class AuthentikService : IAuthentikService { private readonly IHttpClientFactory _httpFactory; private readonly SettingsService _settings; private readonly ILogger _logger; // Cache flow UUIDs so we only look them up once per app lifetime. private string? _cachedAuthorizationFlowUuid; private string? _cachedInvalidationFlowUuid; // XML namespaces used in SAML metadata private static readonly XNamespace Md = "urn:oasis:names:tc:SAML:2.0:metadata"; private static readonly XNamespace Ds = "http://www.w3.org/2000/09/xmldsig#"; public AuthentikService( IHttpClientFactory httpFactory, SettingsService settings, ILogger logger) { _httpFactory = httpFactory; _settings = settings; _logger = logger; } // ───────────────────────────────────────────────────────────────────────── // Public API // ───────────────────────────────────────────────────────────────────────── public async Task ProvisionSamlAsync( string instanceAbbrev, string instanceBaseUrl, CancellationToken ct = default) { _logger.LogInformation("[Authentik] Provisioning SAML for instance {Abbrev}", instanceAbbrev); var (baseUrl, client) = await CreateAuthenticatedClientAsync(); var slug = $"ds-{instanceAbbrev}"; var samlBaseUrl = instanceBaseUrl.TrimEnd('/') + "/saml"; // ── 1. Check if application already exists ──────────────────────── var existingProviderId = await TryGetExistingProviderIdAsync(client, baseUrl, slug, ct); int providerId; if (existingProviderId.HasValue) { _logger.LogInformation("[Authentik] Application '{Slug}' already exists (provider={Id}), reusing", slug, existingProviderId.Value); providerId = existingProviderId.Value; } else { // ── 2. Resolve flow UUIDs ───────────────────────────────────── var authFlowUuid = await ResolveFlowUuidAsync(client, baseUrl, SettingsService.AuthentikAuthorizationFlowSlug, "default-provider-authorization-implicit-consent", ct); var invalidFlowUuid = await ResolveFlowUuidAsync(client, baseUrl, SettingsService.AuthentikInvalidationFlowSlug, "default-provider-invalidation-flow", ct); // ── 3. Create SAML provider ─────────────────────────────────── providerId = await CreateSamlProviderAsync(client, baseUrl, instanceAbbrev, samlBaseUrl, authFlowUuid, invalidFlowUuid, ct); // ── 4. Create application linked to provider ────────────────── await CreateApplicationAsync(client, baseUrl, instanceAbbrev, slug, providerId, ct); } // ── 5. Fetch and parse metadata ─────────────────────────────────── var config = await FetchAndParseMetadataAsync(client, baseUrl, providerId, ct); config.ApplicationSlug = slug; _logger.LogInformation( "[Authentik] SAML provisioned for {Abbrev}: provider={ProviderId}, entityId={EntityId}", instanceAbbrev, config.ProviderId, config.IdpEntityId); return config; } // ───────────────────────────────────────────────────────────────────────── // HTTP client setup // ───────────────────────────────────────────────────────────────────────── private async Task<(string BaseUrl, HttpClient Client)> CreateAuthenticatedClientAsync() { var authentikUrl = await _settings.GetAsync(SettingsService.AuthentikUrl); var apiKey = await _settings.GetAsync(SettingsService.AuthentikApiKey); if (string.IsNullOrWhiteSpace(authentikUrl)) throw new InvalidOperationException("Authentik URL is not configured. Set it in Settings → Authentik."); if (string.IsNullOrWhiteSpace(apiKey)) throw new InvalidOperationException("Authentik API Key is not configured. Set it in Settings → Authentik."); var baseUrl = authentikUrl.TrimEnd('/'); var client = _httpFactory.CreateClient("AuthentikApi"); client.BaseAddress = new Uri(baseUrl); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); return (baseUrl, client); } // ───────────────────────────────────────────────────────────────────────── // Check for existing application // ───────────────────────────────────────────────────────────────────────── private async Task TryGetExistingProviderIdAsync( HttpClient client, string baseUrl, string slug, CancellationToken ct) { try { var resp = await client.GetAsync($"/api/v3/core/applications/?slug={slug}", ct); if (!resp.IsSuccessStatusCode) return null; var json = await resp.Content.ReadFromJsonAsync>(cancellationToken: ct); if (json?.Results is { Count: > 0 }) { var app = json.Results[0]; return app.Provider; } } catch (Exception ex) { _logger.LogDebug(ex, "[Authentik] Could not check for existing application '{Slug}'", slug); } return null; } // ───────────────────────────────────────────────────────────────────────── // Flow resolution // ───────────────────────────────────────────────────────────────────────── private async Task ResolveFlowUuidAsync( HttpClient client, string baseUrl, string settingsKey, string defaultFlowSlug, CancellationToken ct) { // Check if user has configured a specific flow slug in settings var configuredSlug = await _settings.GetAsync(settingsKey); var slug = string.IsNullOrWhiteSpace(configuredSlug) ? defaultFlowSlug : configuredSlug; // Return cached value if available if (settingsKey == SettingsService.AuthentikAuthorizationFlowSlug && _cachedAuthorizationFlowUuid != null) return _cachedAuthorizationFlowUuid; if (settingsKey == SettingsService.AuthentikInvalidationFlowSlug && _cachedInvalidationFlowUuid != null) return _cachedInvalidationFlowUuid; _logger.LogDebug("[Authentik] Resolving flow UUID for slug '{Slug}'", slug); var resp = await client.GetAsync($"/api/v3/flows/instances/?slug={slug}", ct); resp.EnsureSuccessStatusCode(); var json = await resp.Content.ReadFromJsonAsync>(cancellationToken: ct); if (json?.Results is not { Count: > 0 }) throw new InvalidOperationException( $"Authentik flow '{slug}' not found. Ensure the flow exists or configure the correct slug in Settings → Authentik."); var uuid = json.Results[0].Pk; // Cache for subsequent calls if (settingsKey == SettingsService.AuthentikAuthorizationFlowSlug) _cachedAuthorizationFlowUuid = uuid; else if (settingsKey == SettingsService.AuthentikInvalidationFlowSlug) _cachedInvalidationFlowUuid = uuid; return uuid; } // ───────────────────────────────────────────────────────────────────────── // SAML Provider creation // ───────────────────────────────────────────────────────────────────────── private async Task CreateSamlProviderAsync( HttpClient client, string baseUrl, string abbrev, string samlBaseUrl, string authFlowUuid, string invalidFlowUuid, CancellationToken ct) { _logger.LogInformation("[Authentik] Creating SAML provider for {Abbrev}", abbrev); var payload = new Dictionary { ["name"] = $"OTS Signs — {abbrev.ToUpperInvariant()} (SAML)", ["authorization_flow"] = authFlowUuid, ["invalidation_flow"] = invalidFlowUuid, ["acs_url"] = $"{samlBaseUrl}/acs", ["sp_binding"] = "post", ["issuer"] = "authentik", ["audience"] = $"{samlBaseUrl}/metadata", ["default_relay_state"] = "", ["name_id_mapping"] = (object)null!, // use default }; // Optionally add SLO URL payload["sls_url"] = $"{samlBaseUrl}/sls"; payload["sls_binding"] = "redirect"; // Optionally attach signing keypair var signingKpId = await _settings.GetAsync(SettingsService.AuthentikSigningKeypairId); if (!string.IsNullOrWhiteSpace(signingKpId)) payload["signing_kp"] = signingKpId; var resp = await client.PostAsJsonAsync("/api/v3/providers/saml/", payload, ct); if (!resp.IsSuccessStatusCode) { var errorBody = await resp.Content.ReadAsStringAsync(ct); throw new InvalidOperationException( $"Failed to create Authentik SAML provider (HTTP {(int)resp.StatusCode}): {errorBody}"); } var result = await resp.Content.ReadFromJsonAsync(cancellationToken: ct); if (result == null) throw new InvalidOperationException("Authentik returned null when creating SAML provider."); _logger.LogInformation("[Authentik] SAML provider created: id={ProviderId}", result.Pk); return result.Pk; } // ───────────────────────────────────────────────────────────────────────── // Application creation // ───────────────────────────────────────────────────────────────────────── private async Task CreateApplicationAsync( HttpClient client, string baseUrl, string abbrev, string slug, int providerId, CancellationToken ct) { _logger.LogInformation("[Authentik] Creating application '{Slug}' linked to provider {ProviderId}", slug, providerId); var payload = new Dictionary { ["name"] = $"OTS Signs — {abbrev.ToUpperInvariant()}", ["slug"] = slug, ["provider"] = providerId, }; var resp = await client.PostAsJsonAsync("/api/v3/core/applications/", payload, ct); if (!resp.IsSuccessStatusCode) { var errorBody = await resp.Content.ReadAsStringAsync(ct); throw new InvalidOperationException( $"Failed to create Authentik application '{slug}' (HTTP {(int)resp.StatusCode}): {errorBody}"); } _logger.LogInformation("[Authentik] Application '{Slug}' created", slug); } // ───────────────────────────────────────────────────────────────────────── // Metadata retrieval & parsing // ───────────────────────────────────────────────────────────────────────── private async Task FetchAndParseMetadataAsync( HttpClient client, string baseUrl, int providerId, CancellationToken ct) { _logger.LogDebug("[Authentik] Fetching SAML metadata for provider {ProviderId}", providerId); // Request XML metadata (override Accept header for this call) var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v3/providers/saml/{providerId}/metadata/?download"); request.Headers.Accept.Clear(); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/xml")); var resp = await client.SendAsync(request, ct); resp.EnsureSuccessStatusCode(); var xml = await resp.Content.ReadAsStringAsync(ct); return ParseMetadataXml(xml, providerId); } private AuthentikSamlConfig ParseMetadataXml(string xml, int providerId) { var doc = XDocument.Parse(xml); var root = doc.Root ?? throw new InvalidOperationException("SAML metadata XML has no root element."); var config = new AuthentikSamlConfig { ProviderId = providerId, IdpEntityId = root.Attribute("entityID")?.Value ?? "authentik", }; // Extract x509 certificate (signing key) var certElement = root.Descendants(Ds + "X509Certificate").FirstOrDefault(); if (certElement != null) { // Remove whitespace/newlines from cert — some SAML libs expect a single line config.IdpX509Cert = certElement.Value.Replace("\n", "").Replace("\r", "").Trim(); } // Extract SSO URLs foreach (var sso in root.Descendants(Md + "SingleSignOnService")) { var binding = sso.Attribute("Binding")?.Value; var location = sso.Attribute("Location")?.Value; if (location == null) continue; if (binding?.Contains("HTTP-Redirect") == true) config.SsoUrlRedirect = location; else if (binding?.Contains("HTTP-POST") == true) config.SsoUrlPost = location; } // Extract SLO URLs foreach (var slo in root.Descendants(Md + "SingleLogoutService")) { var binding = slo.Attribute("Binding")?.Value; var location = slo.Attribute("Location")?.Value; if (location == null) continue; if (binding?.Contains("HTTP-Redirect") == true) config.SloUrlRedirect = location; else if (binding?.Contains("HTTP-POST") == true) config.SloUrlPost = location; } _logger.LogDebug( "[Authentik] Metadata parsed: entityId={EntityId}, ssoRedirect={SsoUrl}, sloRedirect={SloUrl}, certLen={CertLen}", config.IdpEntityId, config.SsoUrlRedirect, config.SloUrlRedirect, config.IdpX509Cert.Length); return config; } // ───────────────────────────────────────────────────────────────────────── // API response DTOs (internal) // ───────────────────────────────────────────────────────────────────────── private class AuthentikListResponse { [JsonPropertyName("pagination")] public object? Pagination { get; set; } [JsonPropertyName("results")] public List Results { get; set; } = new(); } private class AuthentikFlow { [JsonPropertyName("pk")] public string Pk { get; set; } = string.Empty; [JsonPropertyName("slug")] public string Slug { get; set; } = string.Empty; [JsonPropertyName("name")] public string Name { get; set; } = string.Empty; } private class AuthentikSamlProvider { [JsonPropertyName("pk")] public int Pk { get; set; } [JsonPropertyName("name")] public string Name { get; set; } = string.Empty; [JsonPropertyName("url_sso_redirect")] public string? UrlSsoRedirect { get; set; } [JsonPropertyName("url_sso_post")] public string? UrlSsoPost { get; set; } [JsonPropertyName("url_slo_redirect")] public string? UrlSloRedirect { get; set; } [JsonPropertyName("url_slo_post")] public string? UrlSloPost { get; set; } } private class AuthentikApplication { [JsonPropertyName("pk")] public string Pk { get; set; } = string.Empty; [JsonPropertyName("slug")] public string Slug { get; set; } = string.Empty; [JsonPropertyName("provider")] public int? Provider { get; set; } } }