using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text; 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 providerName = $"OTS Signs — {instanceAbbrev.ToUpperInvariant()} (SAML)"; 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. Find or create SAML provider ────────────────────────── var existingSamlProvider = await TryGetExistingSamlProviderAsync(client, providerName, ct); if (existingSamlProvider.HasValue) { _logger.LogInformation("[Authentik] SAML provider '{Name}' already exists (id={Id}), reusing", providerName, existingSamlProvider.Value); providerId = existingSamlProvider.Value; } else { 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. Ensure provider has a signing keypair (required for metadata) ── await EnsureProviderHasSigningKeypairAsync(client, providerId, ct); // ── 5b. Ensure property mappings are attached (required for valid SAML responses) ── await EnsureProviderHasPropertyMappingsAsync(client, providerId, ct); // ── 6. 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; } // ───────────────────────────────────────────────────────────────────────── // Settings UI helpers // ───────────────────────────────────────────────────────────────────────── /// public async Task<(bool Success, string Message)> TestConnectionAsync( string? overrideUrl = null, string? overrideApiKey = null, CancellationToken ct = default) { try { var (_, client) = await CreateAuthenticatedClientAsync(overrideUrl, overrideApiKey); var resp = await client.GetAsync("/api/v3/core/users/me/", ct); if (!resp.IsSuccessStatusCode) return (false, $"Authentik returned HTTP {(int)resp.StatusCode}."); return (true, "Connected to Authentik successfully."); } catch (Exception ex) { return (false, ex.Message); } } /// public async Task> ListFlowsAsync( string? overrideUrl = null, string? overrideApiKey = null, CancellationToken ct = default) { var (_, client) = await CreateAuthenticatedClientAsync(overrideUrl, overrideApiKey); var resp = await client.GetAsync("/api/v3/flows/instances/?page_size=200", ct); resp.EnsureSuccessStatusCode(); var json = await resp.Content.ReadFromJsonAsync>(cancellationToken: ct); return json?.Results?.Select(f => new AuthentikFlowItem { Pk = f.Pk, Slug = f.Slug, Name = f.Name, Designation = f.Designation, }).OrderBy(f => f.Slug).ToList() ?? new(); } /// public async Task> ListKeypairsAsync( string? overrideUrl = null, string? overrideApiKey = null, CancellationToken ct = default) { var (_, client) = await CreateAuthenticatedClientAsync(overrideUrl, overrideApiKey); var resp = await client.GetAsync("/api/v3/crypto/certificatekeypairs/?page_size=200", ct); resp.EnsureSuccessStatusCode(); var json = await resp.Content.ReadFromJsonAsync>(cancellationToken: ct); return json?.Results?.Select(k => new AuthentikKeypairItem { Pk = k.Pk, Name = k.Name, }).OrderBy(k => k.Name).ToList() ?? new(); } // ───────────────────────────────────────────────────────────────────────── // HTTP client setup // ───────────────────────────────────────────────────────────────────────── private async Task<(string BaseUrl, HttpClient Client)> CreateAuthenticatedClientAsync( string? overrideUrl = null, string? overrideApiKey = null) { var authentikUrl = overrideUrl ?? await _settings.GetAsync(SettingsService.AuthentikUrl); var apiKey = overrideApiKey ?? 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); // Authentik's ?slug= filter may do a partial/contains match, // so verify the slug matches exactly on the client side. var app = json?.Results?.FirstOrDefault(a => string.Equals(a.Slug, slug, StringComparison.OrdinalIgnoreCase)); if (app?.Provider != null) { return app.Provider; } if (json?.Results is { Count: > 0 }) { _logger.LogDebug( "[Authentik] API returned {Count} application(s) for slug query '{Slug}', but none matched exactly. Slugs returned: {Slugs}", json.Results.Count, slug, string.Join(", ", json.Results.Select(a => a.Slug))); } } catch (Exception ex) { _logger.LogDebug(ex, "[Authentik] Could not check for existing application '{Slug}'", slug); } return null; } // ───────────────────────────────────────────────────────────────────────── // Check for existing SAML provider (orphaned from a previous failed run) // ───────────────────────────────────────────────────────────────────────── private async Task TryGetExistingSamlProviderAsync( HttpClient client, string providerName, CancellationToken ct) { try { var resp = await client.GetAsync("/api/v3/providers/saml/?page_size=200", ct); if (!resp.IsSuccessStatusCode) return null; var json = await resp.Content.ReadFromJsonAsync>(cancellationToken: ct); var match = json?.Results?.FirstOrDefault(p => string.Equals(p.Name, providerName, StringComparison.OrdinalIgnoreCase)); return match?.Pk; } catch (Exception ex) { _logger.LogDebug(ex, "[Authentik] Could not check for existing SAML provider '{Name}'", providerName); } return null; } // ───────────────────────────────────────────────────────────────────────── // Keypair resolution // ───────────────────────────────────────────────────────────────────────── /// /// Fetches all certificate keypairs from Authentik and returns the ID of /// the first one found (preferring one whose name contains "authentik" /// or "self-signed"). Returns null if none exist. /// private async Task ResolveDefaultKeypairAsync(HttpClient client, CancellationToken ct) { try { var resp = await client.GetAsync("/api/v3/crypto/certificatekeypairs/?has_key=true&page_size=200", ct); if (!resp.IsSuccessStatusCode) return null; var json = await resp.Content.ReadFromJsonAsync>(cancellationToken: ct); if (json?.Results == null || json.Results.Count == 0) return null; // Prefer Authentik's auto-generated self-signed keypair var preferred = json.Results.FirstOrDefault(k => k.Name.Contains("authentik", StringComparison.OrdinalIgnoreCase) || k.Name.Contains("self-signed", StringComparison.OrdinalIgnoreCase)); var selected = preferred ?? json.Results[0]; _logger.LogInformation("[Authentik] Auto-selected signing keypair: '{Name}' (id={Id})", selected.Name, selected.Pk); return selected.Pk; } catch (Exception ex) { _logger.LogDebug(ex, "[Authentik] Could not auto-detect signing keypair"); return null; } } // ───────────────────────────────────────────────────────────────────────── // SAML property mappings // ───────────────────────────────────────────────────────────────────────── /// /// Fetches built-in SAML property mappings from Authentik and returns their /// IDs. These are attached to the SAML provider so that the SAML response /// includes actual <Attribute> elements inside the <AttributeStatement>. /// Without at least one mapping, Authentik sends an empty AttributeStatement /// which fails strict XML schema validation in php-saml / Xibo. /// private async Task> ResolveSamlPropertyMappingIdsAsync( HttpClient client, CancellationToken ct) { // Authentik 2024+ moved the endpoint to /propertymappings/provider/saml/. // Try the new endpoint first, then fall back to the legacy one. var endpoints = new[] { "/api/v3/propertymappings/provider/saml/?page_size=200", "/api/v3/propertymappings/saml/?page_size=200", }; foreach (var endpoint in endpoints) { try { var resp = await client.GetAsync(endpoint, ct); if (!resp.IsSuccessStatusCode) { _logger.LogDebug("[Authentik] Property mappings endpoint {Endpoint} returned HTTP {Status}", endpoint, (int)resp.StatusCode); continue; } var json = await resp.Content.ReadFromJsonAsync>(cancellationToken: ct); if (json?.Results == null || json.Results.Count == 0) { _logger.LogDebug("[Authentik] Property mappings endpoint {Endpoint} returned 0 results", endpoint); continue; } _logger.LogDebug("[Authentik] Found {Count} SAML property mapping(s) from {Endpoint}", json.Results.Count, endpoint); // Return all managed (built-in) mappings — these are Authentik's default // attribute mappings for username, email, name, groups, UPN, and uid. var managed = json.Results .Where(m => !string.IsNullOrWhiteSpace(m.Managed)) .Select(m => m.Pk) .ToList(); if (managed.Count > 0) { _logger.LogInformation("[Authentik] Using {Count} managed SAML property mapping(s)", managed.Count); return managed; } // Fall back to all available mappings if no managed ones found _logger.LogInformation("[Authentik] No managed mappings found — using all {Count} available mapping(s)", json.Results.Count); return json.Results.Select(m => m.Pk).ToList(); } catch (Exception ex) { _logger.LogDebug(ex, "[Authentik] Error fetching property mappings from {Endpoint}", endpoint); } } _logger.LogWarning("[Authentik] Could not resolve SAML property mappings from any endpoint"); return new List(); } // ───────────────────────────────────────────────────────────────────────── // Ensure provider has a signing keypair // ───────────────────────────────────────────────────────────────────────── /// /// Checks whether the given SAML provider already has a signing keypair. /// If not, resolves one (from settings or auto-detect) and PATCHes it onto the provider. /// Authentik returns a 500 on the metadata endpoint when no keypair is assigned. /// private async Task EnsureProviderHasSigningKeypairAsync( HttpClient client, int providerId, CancellationToken ct) { try { // Fetch the provider details to check for an existing signing_kp var getResp = await client.GetAsync($"/api/v3/providers/saml/{providerId}/", ct); if (!getResp.IsSuccessStatusCode) return; var body = await getResp.Content.ReadAsStringAsync(ct); using var doc = JsonDocument.Parse(body); // signing_kp is null when no keypair is assigned if (doc.RootElement.TryGetProperty("signing_kp", out var signingKp) && signingKp.ValueKind != JsonValueKind.Null) { _logger.LogDebug("[Authentik] Provider {Id} already has a signing keypair", providerId); return; } _logger.LogInformation("[Authentik] Provider {Id} has no signing keypair — patching one", providerId); // Resolve a keypair var kpId = await _settings.GetAsync(SettingsService.AuthentikSigningKeypairId); if (string.IsNullOrWhiteSpace(kpId)) kpId = await ResolveDefaultKeypairAsync(client, ct); if (string.IsNullOrWhiteSpace(kpId)) { _logger.LogWarning("[Authentik] No signing keypair available to patch onto provider {Id}", providerId); return; } var patchPayload = JsonSerializer.Serialize(new Dictionary { ["signing_kp"] = kpId, ["sign_assertion"] = true, ["sign_response"] = true, }); var patchContent = new StringContent(patchPayload, Encoding.UTF8, "application/json"); var patchReq = new HttpRequestMessage(HttpMethod.Patch, $"/api/v3/providers/saml/{providerId}/") { Content = patchContent, }; var patchResp = await client.SendAsync(patchReq, ct); if (patchResp.IsSuccessStatusCode) { _logger.LogInformation("[Authentik] Signing keypair patched onto provider {Id}", providerId); } else { var err = await patchResp.Content.ReadAsStringAsync(ct); _logger.LogWarning("[Authentik] Failed to patch signing keypair onto provider {Id} (HTTP {Status}): {Error}", providerId, (int)patchResp.StatusCode, err); } } catch (Exception ex) { _logger.LogWarning(ex, "[Authentik] Could not ensure signing keypair for provider {Id}", providerId); } } /// /// Checks whether the given SAML provider has property mappings. If not, /// resolves the default set and PATCHes them onto the provider. Without /// mappings, Authentik sends an empty <AttributeStatement> which fails /// strict XML schema validation in php-saml / Xibo. /// private async Task EnsureProviderHasPropertyMappingsAsync( HttpClient client, int providerId, CancellationToken ct) { try { var getResp = await client.GetAsync($"/api/v3/providers/saml/{providerId}/", ct); if (!getResp.IsSuccessStatusCode) return; var body = await getResp.Content.ReadAsStringAsync(ct); using var doc = JsonDocument.Parse(body); // Check if property_mappings already has entries if (doc.RootElement.TryGetProperty("property_mappings", out var mappingsProp) && mappingsProp.ValueKind == JsonValueKind.Array && mappingsProp.GetArrayLength() > 0) { _logger.LogDebug("[Authentik] Provider {Id} already has {Count} property mapping(s)", providerId, mappingsProp.GetArrayLength()); return; } _logger.LogInformation("[Authentik] Provider {Id} has no property mappings — patching defaults", providerId); var mappingIds = await ResolveSamlPropertyMappingIdsAsync(client, ct); if (mappingIds.Count == 0) { _logger.LogWarning("[Authentik] No SAML property mappings available to patch onto provider {Id}", providerId); return; } var patchPayload = JsonSerializer.Serialize(new Dictionary { ["property_mappings"] = mappingIds, }); var patchContent = new StringContent(patchPayload, Encoding.UTF8, "application/json"); var patchReq = new HttpRequestMessage(HttpMethod.Patch, $"/api/v3/providers/saml/{providerId}/") { Content = patchContent, }; var patchResp = await client.SendAsync(patchReq, ct); if (patchResp.IsSuccessStatusCode) { _logger.LogInformation("[Authentik] Property mappings patched onto provider {Id}", providerId); } else { var err = await patchResp.Content.ReadAsStringAsync(ct); _logger.LogWarning("[Authentik] Failed to patch property mappings onto provider {Id} (HTTP {Status}): {Error}", providerId, (int)patchResp.StatusCode, err); } } catch (Exception ex) { _logger.LogWarning(ex, "[Authentik] Could not ensure property mappings for provider {Id}", providerId); } } // ───────────────────────────────────────────────────────────────────────── // 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); // Fetch all flows and filter client-side — some Authentik versions don't // support the ?slug= query parameter or return empty results despite the // flow existing. var resp = await client.GetAsync("/api/v3/flows/instances/?page_size=200", ct); resp.EnsureSuccessStatusCode(); var json = await resp.Content.ReadFromJsonAsync>(cancellationToken: ct); var match = json?.Results?.FirstOrDefault(f => string.Equals(f.Slug, slug, StringComparison.OrdinalIgnoreCase)); if (match == null) { var available = json?.Results?.Select(f => f.Slug) ?? Enumerable.Empty(); _logger.LogWarning("[Authentik] Flow '{Slug}' not found. Available flows: {Flows}", slug, string.Join(", ", available)); throw new InvalidOperationException( $"Authentik flow '{slug}' not found. Ensure the flow exists or configure the correct slug in Settings → Authentik."); } var uuid = match.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"] = $"ds-{abbrev}", ["audience"] = $"{samlBaseUrl}/metadata", ["default_relay_state"] = "", ["sls_url"] = $"{samlBaseUrl}/sls", ["sls_binding"] = "redirect", }; // Attach SAML property mappings so attributes (username, email, etc.) // are included in the response. Without these Authentik sends an empty // which fails strict schema validation in php-saml. var mappingIds = await ResolveSamlPropertyMappingIdsAsync(client, ct); if (mappingIds.Count > 0) { payload["property_mappings"] = mappingIds; _logger.LogInformation("[Authentik] Attaching {Count} SAML property mapping(s) to provider", mappingIds.Count); } else _logger.LogWarning("[Authentik] No SAML property mappings found — SAML responses may fail schema validation"); // Attach signing keypair — required for metadata generation. // Use the configured keypair, or auto-detect one from Authentik. var signingKpId = await _settings.GetAsync(SettingsService.AuthentikSigningKeypairId); if (string.IsNullOrWhiteSpace(signingKpId)) { _logger.LogDebug("[Authentik] No signing keypair configured — auto-detecting from Authentik"); signingKpId = await ResolveDefaultKeypairAsync(client, ct); } if (!string.IsNullOrWhiteSpace(signingKpId)) { payload["signing_kp"] = signingKpId; // Authentik requires at least one of these when a signing keypair is set payload["sign_assertion"] = true; payload["sign_response"] = true; } else _logger.LogWarning("[Authentik] No signing keypair found — metadata generation may fail"); var jsonBody = JsonSerializer.Serialize(payload); _logger.LogDebug("[Authentik] SAML provider request body: {Body}", jsonBody); var content = new StringContent(jsonBody, Encoding.UTF8, "application/json"); var resp = await client.PostAsync("/api/v3/providers/saml/", content, 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 jsonBody = JsonSerializer.Serialize(payload); _logger.LogDebug("[Authentik] Application request body: {Body}", jsonBody); var content = new StringContent(jsonBody, Encoding.UTF8, "application/json"); var resp = await client.PostAsync("/api/v3/core/applications/", content, 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); // Retry a few times — Authentik may need a moment after provider creation const int maxRetries = 3; for (int attempt = 1; attempt <= maxRetries; attempt++) { // The API endpoint returns JSON: { "metadata": "", "download_url": "..." } var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v3/providers/saml/{providerId}/metadata/"); request.Headers.Accept.Clear(); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); var resp = await client.SendAsync(request, ct); if (resp.IsSuccessStatusCode) { var jsonBody = await resp.Content.ReadAsStringAsync(ct); using var doc = JsonDocument.Parse(jsonBody); if (doc.RootElement.TryGetProperty("metadata", out var metadataProp)) { var xml = metadataProp.GetString(); if (!string.IsNullOrWhiteSpace(xml)) return ParseMetadataXml(xml, providerId); } _logger.LogWarning( "[Authentik] Metadata response for provider {Id} missing 'metadata' field: {Body}", providerId, jsonBody); } else { var body = await resp.Content.ReadAsStringAsync(ct); _logger.LogWarning( "[Authentik] Metadata fetch attempt {Attempt}/{Max} failed (HTTP {Status}): {Body}", attempt, maxRetries, (int)resp.StatusCode, body.Length > 500 ? body[..500] + "…(truncated)" : body); } if (attempt < maxRetries) await Task.Delay(TimeSpan.FromSeconds(2 * attempt), ct); } // ── Fallback: assemble config from provider detail + keypair cert ── // Authentik's metadata endpoint can return 500 in some versions. // All the data we need is available from other endpoints. _logger.LogWarning( "[Authentik] Metadata endpoint failed after {Max} attempts — assembling config from provider detail + keypair", maxRetries); return await BuildConfigFromProviderDetailAsync(client, baseUrl, providerId, ct); } /// /// Builds the SAML config by reading the provider detail (SSO/SLO URLs) /// and the signing certificate from the keypair endpoint. This is used as /// a fallback when the /metadata/ endpoint returns an error. /// private async Task BuildConfigFromProviderDetailAsync( HttpClient client, string baseUrl, int providerId, CancellationToken ct) { // ── 1. Fetch provider detail ────────────────────────────────────── var provResp = await client.GetAsync($"/api/v3/providers/saml/{providerId}/", ct); if (!provResp.IsSuccessStatusCode) { var err = await provResp.Content.ReadAsStringAsync(ct); throw new InvalidOperationException( $"Cannot fetch SAML provider {providerId} detail (HTTP {(int)provResp.StatusCode}): {err}"); } var provBody = await provResp.Content.ReadAsStringAsync(ct); using var provDoc = JsonDocument.Parse(provBody); var prov = provDoc.RootElement; var config = new AuthentikSamlConfig { ProviderId = providerId }; // Entity ID = the issuer we set on the provider config.IdpEntityId = prov.TryGetProperty("issuer", out var iss) && iss.ValueKind == JsonValueKind.String ? iss.GetString() ?? baseUrl : baseUrl; // SSO / SLO URLs are computed properties on the provider config.SsoUrlRedirect = GetStringProp(prov, "url_sso_redirect"); config.SsoUrlPost = GetStringProp(prov, "url_sso_post"); config.SloUrlRedirect = GetStringProp(prov, "url_slo_redirect"); config.SloUrlPost = GetStringProp(prov, "url_slo_post"); _logger.LogDebug( "[Authentik] Provider detail: entityId={EntityId}, ssoRedirect={Sso}, sloRedirect={Slo}", config.IdpEntityId, config.SsoUrlRedirect, config.SloUrlRedirect); // ── 2. Fetch X.509 certificate from the signing keypair ─────────── if (prov.TryGetProperty("signing_kp", out var kpProp) && kpProp.ValueKind != JsonValueKind.Null) { var kpId = kpProp.GetString(); if (!string.IsNullOrWhiteSpace(kpId)) { config.IdpX509Cert = await FetchKeypairCertificateAsync(client, kpId, ct); } } if (string.IsNullOrWhiteSpace(config.IdpX509Cert)) _logger.LogWarning("[Authentik] Could not retrieve X.509 certificate for provider {Id}", providerId); _logger.LogInformation( "[Authentik] Config assembled from provider detail: entityId={EntityId}, certLen={CertLen}", config.IdpEntityId, config.IdpX509Cert?.Length ?? 0); return config; } /// /// Fetches the PEM certificate from Authentik's keypair endpoints and /// returns the base64-encoded X.509 body (no PEM headers). /// Tries /view_certificate/ first, then falls back to the regular /// keypair detail and certificate_data. /// private async Task FetchKeypairCertificateAsync( HttpClient client, string keypairId, CancellationToken ct) { // Attempt 1: /view_certificate/ endpoint (returns detailed cert info) try { var resp = await client.GetAsync( $"/api/v3/crypto/certificatekeypairs/{keypairId}/view_certificate/", ct); if (resp.IsSuccessStatusCode) { var body = await resp.Content.ReadAsStringAsync(ct); using var doc = JsonDocument.Parse(body); // The cert field contains PEM-encoded certificate foreach (var fieldName in new[] { "cert", "certificate", "data" }) { if (doc.RootElement.TryGetProperty(fieldName, out var certProp) && certProp.ValueKind == JsonValueKind.String) { var pem = certProp.GetString(); if (!string.IsNullOrWhiteSpace(pem)) { _logger.LogDebug("[Authentik] Certificate retrieved from view_certificate/{Field}", fieldName); return StripPemHeaders(pem); } } } _logger.LogDebug("[Authentik] view_certificate response had no cert field: {Body}", body.Length > 500 ? body[..500] : body); } } catch (Exception ex) { _logger.LogDebug(ex, "[Authentik] view_certificate endpoint failed for keypair {Id}", keypairId); } // Attempt 2: regular keypair detail (some versions include certificate_data) try { var resp = await client.GetAsync( $"/api/v3/crypto/certificatekeypairs/{keypairId}/", ct); if (resp.IsSuccessStatusCode) { var body = await resp.Content.ReadAsStringAsync(ct); using var doc = JsonDocument.Parse(body); foreach (var fieldName in new[] { "certificate_data", "cert", "certificate" }) { if (doc.RootElement.TryGetProperty(fieldName, out var certProp) && certProp.ValueKind == JsonValueKind.String) { var pem = certProp.GetString(); if (!string.IsNullOrWhiteSpace(pem)) { _logger.LogDebug("[Authentik] Certificate retrieved from keypair detail/{Field}", fieldName); return StripPemHeaders(pem); } } } _logger.LogDebug("[Authentik] Keypair detail response fields: {Fields}", string.Join(", ", EnumeratePropertyNames(doc.RootElement))); } } catch (Exception ex) { _logger.LogDebug(ex, "[Authentik] Keypair detail fetch failed for {Id}", keypairId); } return string.Empty; } private static string StripPemHeaders(string pem) { return pem .Replace("-----BEGIN CERTIFICATE-----", "") .Replace("-----END CERTIFICATE-----", "") .Replace("\n", "") .Replace("\r", "") .Trim(); } private static string GetStringProp(JsonElement el, string name) { return el.TryGetProperty(name, out var prop) && prop.ValueKind == JsonValueKind.String ? prop.GetString() ?? string.Empty : string.Empty; } private static IEnumerable EnumeratePropertyNames(JsonElement el) { if (el.ValueKind == JsonValueKind.Object) { foreach (var p in el.EnumerateObject()) yield return p.Name; } } 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 AuthentikFlowDetailed { [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; [JsonPropertyName("designation")] public string Designation { get; set; } = string.Empty; } private class AuthentikKeypairDetailed { [JsonPropertyName("pk")] public string Pk { 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; } } private class AuthentikPropertyMapping { [JsonPropertyName("pk")] public string Pk { get; set; } = string.Empty; [JsonPropertyName("name")] public string Name { get; set; } = string.Empty; /// /// Non-null for built-in managed mappings, e.g. "goauthentik.io/providers/saml/upn". /// [JsonPropertyName("managed")] public string? Managed { get; set; } } }