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, instanceBaseUrl, 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); // ── 5c. Ensure usertypeid property mapping is created and attached ── // (for admin group assignment via SAML) await EnsureUserTypeidMappingAsync(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(); } /// public async Task> ListGroupsAsync( string? overrideUrl = null, string? overrideApiKey = null, CancellationToken ct = default) { var (_, client) = await CreateAuthenticatedClientAsync(overrideUrl, overrideApiKey); // Authentik has moved the groups endpoint between versions. // Try each known path until one succeeds. var endpoints = new[] { "/api/v3/core/groups/", "/api/v3/groups/", "/api/v3/core/group/", }; string? workingEndpoint = null; foreach (var ep in endpoints) { var probe = await client.GetAsync($"{ep}?page_size=1", ct); if (probe.IsSuccessStatusCode) { workingEndpoint = ep; _logger.LogDebug("[Authentik] Groups endpoint resolved: {Endpoint}", ep); break; } _logger.LogDebug("[Authentik] Groups endpoint {Endpoint} returned HTTP {Status}", ep, (int)probe.StatusCode); } if (workingEndpoint == null) { _logger.LogWarning("[Authentik] No working groups endpoint found — tried: {Endpoints}", string.Join(", ", endpoints)); return new List(); } var groups = new List(); var nextUrl = $"{workingEndpoint}?page_size=200"; while (!string.IsNullOrEmpty(nextUrl)) { var resp = await client.GetAsync(nextUrl, ct); if (!resp.IsSuccessStatusCode) { _logger.LogWarning("[Authentik] Groups page request failed (HTTP {Status})", (int)resp.StatusCode); break; } var body = await resp.Content.ReadAsStringAsync(ct); using var doc = JsonDocument.Parse(body); var root = doc.RootElement; if (root.TryGetProperty("results", out var results) && results.ValueKind == JsonValueKind.Array) { foreach (var g in results.EnumerateArray()) { var pk = g.TryGetProperty("pk", out var pkProp) ? pkProp.GetString() ?? "" : ""; var name = g.TryGetProperty("name", out var nProp) ? nProp.GetString() ?? "" : ""; var memberCount = g.TryGetProperty("users_obj", out var usersObj) && usersObj.ValueKind == JsonValueKind.Array ? usersObj.GetArrayLength() : (g.TryGetProperty("users", out var users) && users.ValueKind == JsonValueKind.Array ? users.GetArrayLength() : 0); // Skip Authentik built-in groups (authentik Admins, etc.) if (!string.IsNullOrEmpty(name) && !name.StartsWith("authentik ", StringComparison.OrdinalIgnoreCase)) { groups.Add(new AuthentikGroupItem { Pk = pk, Name = name, MemberCount = memberCount, }); } } } // Handle pagination nextUrl = root.TryGetProperty("pagination", out var pagination) && pagination.TryGetProperty("next", out var nextProp) && nextProp.ValueKind == JsonValueKind.Number ? $"{workingEndpoint}?page_size=200&page={nextProp.GetInt32()}" : null; } _logger.LogInformation("[Authentik] Found {Count} group(s)", groups.Count); return groups.OrderBy(g => g.Name).ToList(); } // ───────────────────────────────────────────────────────────────────────── // 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); } } /// /// Ensures that the SAML property mapping for usertypeid (group-based admin assignment) exists. /// Creates it if missing and attaches it to the provider. /// The mapping returns "1" (super-admin) for users in the "OTS IT" group, empty string otherwise. /// private async Task EnsureUserTypeidMappingAsync( HttpClient client, int providerId, CancellationToken ct) { try { _logger.LogInformation("[Authentik] Ensuring usertypeid property mapping exists for provider {Id}", providerId); // ── 1. Try to find existing mapping by name ──────────────────────── var mappingId = await TryFindUserTypeidMappingAsync(client, ct); if (!string.IsNullOrEmpty(mappingId)) { _logger.LogInformation("[Authentik] Found existing usertypeid mapping: {Id}", mappingId); } else { // ── 2. Create new mapping ──────────────────────────────────── mappingId = await CreateUserTypeidMappingAsync(client, ct); if (string.IsNullOrEmpty(mappingId)) { _logger.LogWarning("[Authentik] Could not create usertypeid mapping"); return; } } // ── 3. Attach mapping to provider ──────────────────────────────── await AttachUserTypeidMappingToProviderAsync(client, providerId, mappingId, ct); } catch (Exception ex) { _logger.LogWarning(ex, "[Authentik] Could not ensure usertypeid mapping for provider {Id}", providerId); } } /// /// Looks for an existing SAML property mapping named "saml-usertypeid". /// Returns its ID if found, null otherwise. /// private async Task TryFindUserTypeidMappingAsync(HttpClient client, CancellationToken ct) { try { 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) continue; var json = await resp.Content.ReadFromJsonAsync>(cancellationToken: ct); var match = json?.Results?.FirstOrDefault(m => string.Equals(m.Name, "saml-usertypeid", StringComparison.OrdinalIgnoreCase)); if (match != null) { _logger.LogDebug("[Authentik] Found usertypeid mapping: {Id} ({Name})", match.Pk, match.Name); return match.Pk; } } catch (Exception ex) { _logger.LogDebug(ex, "[Authentik] Error searching property mappings at {Endpoint}", endpoint); } } } catch (Exception ex) { _logger.LogDebug(ex, "[Authentik] Could not search for existing usertypeid mapping"); } return null; } /// /// Creates a new SAML property mapping named "saml-usertypeid" that returns "1" /// for users in the "OTS IT" group and empty string otherwise. /// private async Task CreateUserTypeidMappingAsync(HttpClient client, CancellationToken ct) { try { _logger.LogInformation("[Authentik] Creating usertypeid property mapping"); var expression = @"return ""1"" if user.groups.all() | selectattr(""name"", ""equalto"", ""OTS IT"") else """""; var payload = new Dictionary { ["name"] = "saml-usertypeid", ["saml_name"] = "usertypeid", ["expression"] = expression, }; var jsonBody = JsonSerializer.Serialize(payload); var content = new StringContent(jsonBody, Encoding.UTF8, "application/json"); var resp = await client.PostAsync("/api/v3/propertymappings/provider/saml/", content, ct); if (!resp.IsSuccessStatusCode) { var errorBody = await resp.Content.ReadAsStringAsync(ct); _logger.LogWarning("[Authentik] Failed to create usertypeid mapping (HTTP {Status}): {Error}", (int)resp.StatusCode, errorBody); return null; } var result = await resp.Content.ReadFromJsonAsync(cancellationToken: ct); if (result?.Pk != null) { _logger.LogInformation("[Authentik] Created usertypeid mapping: {Id}", result.Pk); return result.Pk; } _logger.LogWarning("[Authentik] Created usertypeid mapping but response had no Pk"); return null; } catch (Exception ex) { _logger.LogWarning(ex, "[Authentik] Error creating usertypeid mapping"); return null; } } /// /// Attaches the usertypeid property mapping to the given SAML provider /// so it's included in outgoing SAML assertions. /// private async Task AttachUserTypeidMappingToProviderAsync( HttpClient client, int providerId, string mappingId, CancellationToken ct) { try { _logger.LogInformation("[Authentik] Attaching usertypeid mapping {Id} to provider {ProviderId}", mappingId, providerId); // ── 1. Fetch current provider to get existing mappings ─────────── var getResp = await client.GetAsync($"/api/v3/providers/saml/{providerId}/", ct); if (!getResp.IsSuccessStatusCode) { _logger.LogWarning("[Authentik] Could not fetch provider {Id} (HTTP {Status})", providerId, (int)getResp.StatusCode); return; } var body = await getResp.Content.ReadAsStringAsync(ct); using var doc = JsonDocument.Parse(body); var mappings = new List(); // ── 2. Get existing mappings ─────────────────────────────────── if (doc.RootElement.TryGetProperty("property_mappings", out var mapsProp) && mapsProp.ValueKind == JsonValueKind.Array) { foreach (var mapEl in mapsProp.EnumerateArray()) { var mapId = mapEl.ValueKind == JsonValueKind.String ? mapEl.GetString() : (mapEl.TryGetProperty("pk", out var pkProp) ? pkProp.GetString() : null); if (!string.IsNullOrEmpty(mapId) && !mappings.Contains(mapId)) mappings.Add(mapId); } } // ── 3. Add new mapping if not already present ────────────────── if (!mappings.Contains(mappingId)) mappings.Add(mappingId); // ── 4. Patch provider with updated mappings ─────────────────── var patchPayload = JsonSerializer.Serialize(new Dictionary { ["property_mappings"] = mappings, }); 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] Usertypeid mapping attached to provider {Id}", providerId); } else { var err = await patchResp.Content.ReadAsStringAsync(ct); _logger.LogWarning("[Authentik] Failed to attach usertypeid mapping to provider {Id} (HTTP {Status}): {Error}", providerId, (int)patchResp.StatusCode, err); } } catch (Exception ex) { _logger.LogWarning(ex, "[Authentik] Error attaching usertypeid mapping to 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, string instanceBaseUrl, 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, ["meta_launch_url"] = instanceBaseUrl.TrimEnd('/'), }; 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; } // ───────────────────────────────────────────────────────────────────────── // Customer invitation infrastructure // ───────────────────────────────────────────────────────────────────────── /// public async Task CreateGroupAsync(string groupName, CancellationToken ct = default) { _logger.LogInformation("[Authentik] Creating group '{Name}'", groupName); var (_, client) = await CreateAuthenticatedClientAsync(); // Check if the group already exists var existingPk = await TryGetGroupPkByNameAsync(client, groupName, ct); if (existingPk != null) { _logger.LogInformation("[Authentik] Group '{Name}' already exists (pk={Pk})", groupName, existingPk); return existingPk; } var payload = JsonSerializer.Serialize(new Dictionary { ["name"] = groupName }); var content = new StringContent(payload, Encoding.UTF8, "application/json"); var resp = await client.PostAsync("/api/v3/core/groups/", content, ct); if (!resp.IsSuccessStatusCode) { var error = await resp.Content.ReadAsStringAsync(ct); throw new InvalidOperationException($"Failed to create Authentik group '{groupName}' (HTTP {(int)resp.StatusCode}): {error}"); } var body = await resp.Content.ReadAsStringAsync(ct); using var doc = JsonDocument.Parse(body); var pk = doc.RootElement.GetProperty("pk").GetString() ?? throw new InvalidOperationException("Authentik returned null PK for created group."); _logger.LogInformation("[Authentik] Group '{Name}' created (pk={Pk})", groupName, pk); return pk; } /// public async Task CreateInvitationStageAsync( string stageName, bool continueWithoutInvitation = false, CancellationToken ct = default) { _logger.LogInformation("[Authentik] Creating invitation stage '{Name}'", stageName); var (_, client) = await CreateAuthenticatedClientAsync(); // Check if stage already exists var existingPk = await FindStageByNameAsync(stageName, ct); if (existingPk != null) { _logger.LogInformation("[Authentik] Invitation stage '{Name}' already exists (pk={Pk})", stageName, existingPk); return existingPk; } var payload = JsonSerializer.Serialize(new Dictionary { ["name"] = stageName, ["continue_flow_without_invitation"] = continueWithoutInvitation, }); var content = new StringContent(payload, Encoding.UTF8, "application/json"); var resp = await client.PostAsync("/api/v3/stages/invitation/stages/", content, ct); if (!resp.IsSuccessStatusCode) { var error = await resp.Content.ReadAsStringAsync(ct); throw new InvalidOperationException( $"Failed to create invitation stage '{stageName}' (HTTP {(int)resp.StatusCode}): {error}"); } var body = await resp.Content.ReadAsStringAsync(ct); using var doc = JsonDocument.Parse(body); var pk = doc.RootElement.GetProperty("pk").GetString() ?? throw new InvalidOperationException("Authentik returned null PK for created invitation stage."); _logger.LogInformation("[Authentik] Invitation stage '{Name}' created (pk={Pk})", stageName, pk); return pk; } /// public async Task CreateEnrollmentFlowAsync(string name, string slug, CancellationToken ct = default) { _logger.LogInformation("[Authentik] Creating enrollment flow '{Name}' (slug={Slug})", name, slug); var (_, client) = await CreateAuthenticatedClientAsync(); // Check if flow already exists by slug var existingPk = await TryGetFlowPkBySlugAsync(client, slug, ct); if (existingPk != null) { _logger.LogInformation("[Authentik] Flow '{Slug}' already exists (pk={Pk})", slug, existingPk); return existingPk; } var payload = JsonSerializer.Serialize(new Dictionary { ["name"] = name, ["slug"] = slug, ["designation"] = "enrollment", ["title"] = name, }); var content = new StringContent(payload, Encoding.UTF8, "application/json"); var resp = await client.PostAsync("/api/v3/flows/instances/", content, ct); if (!resp.IsSuccessStatusCode) { var error = await resp.Content.ReadAsStringAsync(ct); throw new InvalidOperationException( $"Failed to create enrollment flow '{slug}' (HTTP {(int)resp.StatusCode}): {error}"); } var body = await resp.Content.ReadAsStringAsync(ct); using var doc = JsonDocument.Parse(body); var pk = doc.RootElement.GetProperty("pk").GetString() ?? throw new InvalidOperationException("Authentik returned null PK for created flow."); _logger.LogInformation("[Authentik] Enrollment flow '{Slug}' created (pk={Pk})", slug, pk); return pk; } /// public async Task BindStageToFlowAsync(string flowSlug, string stagePk, int order, CancellationToken ct = default) { _logger.LogInformation("[Authentik] Binding stage {StagePk} to flow '{Slug}' at order {Order}", stagePk, flowSlug, order); var (_, client) = await CreateAuthenticatedClientAsync(); // Check if binding already exists at this order var existingBinding = await GetFlowStageBindingPkAsync(flowSlug, order, ct); if (existingBinding != null) { _logger.LogInformation("[Authentik] Flow '{Slug}' already has a stage binding at order {Order} — skipping", flowSlug, order); return; } var payload = JsonSerializer.Serialize(new Dictionary { ["target"] = flowSlug, ["stage"] = stagePk, ["order"] = order, }); var content = new StringContent(payload, Encoding.UTF8, "application/json"); var resp = await client.PostAsync("/api/v3/flows/bindings/", content, ct); if (!resp.IsSuccessStatusCode) { var error = await resp.Content.ReadAsStringAsync(ct); throw new InvalidOperationException( $"Failed to bind stage to flow '{flowSlug}' at order {order} (HTTP {(int)resp.StatusCode}): {error}"); } _logger.LogInformation("[Authentik] Stage bound to flow '{Slug}' at order {Order}", flowSlug, order); } /// public async Task CreateExpressionPolicyAsync(string name, string expression, CancellationToken ct = default) { _logger.LogInformation("[Authentik] Creating expression policy '{Name}'", name); var (_, client) = await CreateAuthenticatedClientAsync(); // Check if policy already exists var existingPk = await TryGetPolicyPkByNameAsync(client, name, ct); if (existingPk != null) { _logger.LogInformation("[Authentik] Expression policy '{Name}' already exists (pk={Pk})", name, existingPk); return existingPk; } var payload = JsonSerializer.Serialize(new Dictionary { ["name"] = name, ["expression"] = expression, ["execution_logging"] = true, }); var content = new StringContent(payload, Encoding.UTF8, "application/json"); var resp = await client.PostAsync("/api/v3/policies/expression/", content, ct); if (!resp.IsSuccessStatusCode) { var error = await resp.Content.ReadAsStringAsync(ct); throw new InvalidOperationException( $"Failed to create expression policy '{name}' (HTTP {(int)resp.StatusCode}): {error}"); } var body = await resp.Content.ReadAsStringAsync(ct); using var doc = JsonDocument.Parse(body); var pk = doc.RootElement.GetProperty("pk").GetString() ?? throw new InvalidOperationException("Authentik returned null PK for created policy."); _logger.LogInformation("[Authentik] Expression policy '{Name}' created (pk={Pk})", name, pk); return pk; } /// public async Task BindPolicyToFlowStageBoundAsync( string flowStageBindingPk, string policyPk, CancellationToken ct = default) { _logger.LogInformation("[Authentik] Binding policy {PolicyPk} to flow-stage binding {FsbPk}", policyPk, flowStageBindingPk); var (_, client) = await CreateAuthenticatedClientAsync(); var payload = JsonSerializer.Serialize(new Dictionary { ["target"] = flowStageBindingPk, ["policy"] = policyPk, ["order"] = 0, }); var content = new StringContent(payload, Encoding.UTF8, "application/json"); var resp = await client.PostAsync("/api/v3/policies/bindings/", content, ct); if (!resp.IsSuccessStatusCode) { var error = await resp.Content.ReadAsStringAsync(ct); throw new InvalidOperationException( $"Failed to bind policy to flow-stage binding (HTTP {(int)resp.StatusCode}): {error}"); } _logger.LogInformation("[Authentik] Policy bound to flow-stage binding"); } /// public async Task BindPolicyToFlowAsync(string flowSlug, string policyPk, CancellationToken ct = default) { _logger.LogInformation("[Authentik] Binding policy {PolicyPk} to flow '{Slug}'", policyPk, flowSlug); var (_, client) = await CreateAuthenticatedClientAsync(); // Get the flow PK from slug var flowPk = await TryGetFlowPkBySlugAsync(client, flowSlug, ct) ?? throw new InvalidOperationException($"Flow '{flowSlug}' not found."); var payload = JsonSerializer.Serialize(new Dictionary { ["target"] = flowPk, ["policy"] = policyPk, ["order"] = 0, }); var content = new StringContent(payload, Encoding.UTF8, "application/json"); var resp = await client.PostAsync("/api/v3/policies/bindings/", content, ct); if (!resp.IsSuccessStatusCode) { var error = await resp.Content.ReadAsStringAsync(ct); throw new InvalidOperationException( $"Failed to bind policy to flow '{flowSlug}' (HTTP {(int)resp.StatusCode}): {error}"); } _logger.LogInformation("[Authentik] Policy bound to flow '{Slug}'", flowSlug); } /// public async Task CreateRoleAsync(string roleName, CancellationToken ct = default) { _logger.LogInformation("[Authentik] Creating role '{Name}'", roleName); var (_, client) = await CreateAuthenticatedClientAsync(); // Check if role already exists var existingPk = await TryGetRolePkByNameAsync(client, roleName, ct); if (existingPk != null) { _logger.LogInformation("[Authentik] Role '{Name}' already exists (pk={Pk})", roleName, existingPk); return existingPk; } var payload = JsonSerializer.Serialize(new Dictionary { ["name"] = roleName }); var content = new StringContent(payload, Encoding.UTF8, "application/json"); var resp = await client.PostAsync("/api/v3/rbac/roles/", content, ct); if (!resp.IsSuccessStatusCode) { var error = await resp.Content.ReadAsStringAsync(ct); throw new InvalidOperationException( $"Failed to create Authentik role '{roleName}' (HTTP {(int)resp.StatusCode}): {error}"); } var body = await resp.Content.ReadAsStringAsync(ct); using var doc = JsonDocument.Parse(body); var pk = doc.RootElement.GetProperty("pk").GetString() ?? throw new InvalidOperationException("Authentik returned null PK for created role."); _logger.LogInformation("[Authentik] Role '{Name}' created (pk={Pk})", roleName, pk); return pk; } /// public async Task AssignPermissionsToRoleAsync( string rolePk, IEnumerable permissionCodenames, CancellationToken ct = default) { _logger.LogInformation("[Authentik] Assigning permissions to role {RolePk}", rolePk); var (_, client) = await CreateAuthenticatedClientAsync(); // Resolve permission IDs from codenames var permIds = await ResolvePermissionIdsAsync(client, permissionCodenames, ct); if (permIds.Count == 0) { _logger.LogWarning("[Authentik] No permission IDs resolved — skipping assignment"); return; } var payload = JsonSerializer.Serialize(new Dictionary { ["permissions"] = permIds, }); var content = new StringContent(payload, Encoding.UTF8, "application/json"); var resp = await client.PostAsync($"/api/v3/rbac/roles/{rolePk}/assign_permission/", content, ct); // Some Authentik versions use a PATCH or different endpoint structure. // Try alternative if POST fails. if (!resp.IsSuccessStatusCode) { // Fallback: try assigning via the permissions endpoint with model/object_pk _logger.LogDebug("[Authentik] Direct permission assignment returned HTTP {Status}, trying alternative approach", (int)resp.StatusCode); foreach (var permId in permIds) { var singlePayload = JsonSerializer.Serialize(new Dictionary { ["permission"] = permId, ["role"] = rolePk, }); var singleContent = new StringContent(singlePayload, Encoding.UTF8, "application/json"); var singleResp = await client.PostAsync("/api/v3/rbac/permissions/assigned_by_roles/assign/", singleContent, ct); if (!singleResp.IsSuccessStatusCode) { var error = await singleResp.Content.ReadAsStringAsync(ct); _logger.LogWarning("[Authentik] Failed to assign permission {PermId} to role (HTTP {Status}): {Error}", permId, (int)singleResp.StatusCode, error); } } } _logger.LogInformation("[Authentik] Permissions assigned to role {RolePk}", rolePk); } /// public async Task AssignRoleToGroupAsync(string rolePk, string groupPk, CancellationToken ct = default) { _logger.LogInformation("[Authentik] Assigning role {RolePk} to group {GroupPk}", rolePk, groupPk); var (_, client) = await CreateAuthenticatedClientAsync(); // Authentik manages role→group mapping via the group's roles list // PATCH the group to add the role var getResp = await client.GetAsync($"/api/v3/core/groups/{groupPk}/", ct); if (!getResp.IsSuccessStatusCode) { var error = await getResp.Content.ReadAsStringAsync(ct); throw new InvalidOperationException( $"Failed to fetch group {groupPk} (HTTP {(int)getResp.StatusCode}): {error}"); } var groupBody = await getResp.Content.ReadAsStringAsync(ct); using var groupDoc = JsonDocument.Parse(groupBody); var existingRoles = new List(); if (groupDoc.RootElement.TryGetProperty("roles", out var rolesProp) && rolesProp.ValueKind == JsonValueKind.Array) { foreach (var r in rolesProp.EnumerateArray()) { var val = r.GetString(); if (!string.IsNullOrEmpty(val)) existingRoles.Add(val); } } if (!existingRoles.Contains(rolePk)) existingRoles.Add(rolePk); var patchPayload = JsonSerializer.Serialize(new Dictionary { ["roles"] = existingRoles, }); var patchContent = new StringContent(patchPayload, Encoding.UTF8, "application/json"); var patchReq = new HttpRequestMessage(HttpMethod.Patch, $"/api/v3/core/groups/{groupPk}/") { Content = patchContent, }; var patchResp = await client.SendAsync(patchReq, ct); if (!patchResp.IsSuccessStatusCode) { var error = await patchResp.Content.ReadAsStringAsync(ct); throw new InvalidOperationException( $"Failed to assign role to group (HTTP {(int)patchResp.StatusCode}): {error}"); } _logger.LogInformation("[Authentik] Role {RolePk} assigned to group {GroupPk}", rolePk, groupPk); } /// public async Task GetFlowStageBindingPkAsync(string flowSlug, int order, CancellationToken ct = default) { var (_, client) = await CreateAuthenticatedClientAsync(); var resp = await client.GetAsync($"/api/v3/flows/bindings/?target={flowSlug}&ordering=order", ct); if (!resp.IsSuccessStatusCode) return null; var body = await resp.Content.ReadAsStringAsync(ct); using var doc = JsonDocument.Parse(body); if (doc.RootElement.TryGetProperty("results", out var results) && results.ValueKind == JsonValueKind.Array) { foreach (var binding in results.EnumerateArray()) { if (binding.TryGetProperty("order", out var orderProp) && orderProp.GetInt32() == order) { return binding.TryGetProperty("pk", out var pkProp) ? pkProp.GetString() : null; } } } return null; } /// public async Task FindStageByNameAsync(string nameContains, CancellationToken ct = default) { var (_, client) = await CreateAuthenticatedClientAsync(); var resp = await client.GetAsync($"/api/v3/stages/all/?search={Uri.EscapeDataString(nameContains)}&page_size=50", ct); if (!resp.IsSuccessStatusCode) return null; var body = await resp.Content.ReadAsStringAsync(ct); using var doc = JsonDocument.Parse(body); if (doc.RootElement.TryGetProperty("results", out var results) && results.ValueKind == JsonValueKind.Array) { foreach (var stage in results.EnumerateArray()) { var name = stage.TryGetProperty("name", out var nameProp) ? nameProp.GetString() : null; if (name != null && name.Contains(nameContains, StringComparison.OrdinalIgnoreCase)) { return stage.TryGetProperty("pk", out var pkProp) ? pkProp.GetString() : null; } } } return null; } // ───────────────────────────────────────────────────────────────────────── // Invitation infrastructure helpers // ───────────────────────────────────────────────────────────────────────── private async Task TryGetGroupPkByNameAsync(HttpClient client, string name, CancellationToken ct) { try { var resp = await client.GetAsync($"/api/v3/core/groups/?search={Uri.EscapeDataString(name)}&page_size=50", ct); if (!resp.IsSuccessStatusCode) return null; var body = await resp.Content.ReadAsStringAsync(ct); using var doc = JsonDocument.Parse(body); if (doc.RootElement.TryGetProperty("results", out var results) && results.ValueKind == JsonValueKind.Array) { foreach (var g in results.EnumerateArray()) { var gName = g.TryGetProperty("name", out var nProp) ? nProp.GetString() : null; if (string.Equals(gName, name, StringComparison.OrdinalIgnoreCase)) return g.TryGetProperty("pk", out var pkProp) ? pkProp.GetString() : null; } } } catch (Exception ex) { _logger.LogDebug(ex, "[Authentik] Error checking for existing group '{Name}'", name); } return null; } private async Task TryGetFlowPkBySlugAsync(HttpClient client, string slug, CancellationToken ct) { try { var resp = await client.GetAsync($"/api/v3/flows/instances/?slug={Uri.EscapeDataString(slug)}", ct); if (!resp.IsSuccessStatusCode) return null; var body = await resp.Content.ReadAsStringAsync(ct); using var doc = JsonDocument.Parse(body); if (doc.RootElement.TryGetProperty("results", out var results) && results.ValueKind == JsonValueKind.Array) { foreach (var f in results.EnumerateArray()) { var fSlug = f.TryGetProperty("slug", out var sProp) ? sProp.GetString() : null; if (string.Equals(fSlug, slug, StringComparison.OrdinalIgnoreCase)) return f.TryGetProperty("pk", out var pkProp) ? pkProp.GetString() : null; } } } catch (Exception ex) { _logger.LogDebug(ex, "[Authentik] Error checking for existing flow '{Slug}'", slug); } return null; } private async Task TryGetPolicyPkByNameAsync(HttpClient client, string name, CancellationToken ct) { try { var resp = await client.GetAsync($"/api/v3/policies/expression/?search={Uri.EscapeDataString(name)}&page_size=50", ct); if (!resp.IsSuccessStatusCode) return null; var body = await resp.Content.ReadAsStringAsync(ct); using var doc = JsonDocument.Parse(body); if (doc.RootElement.TryGetProperty("results", out var results) && results.ValueKind == JsonValueKind.Array) { foreach (var p in results.EnumerateArray()) { var pName = p.TryGetProperty("name", out var nProp) ? nProp.GetString() : null; if (string.Equals(pName, name, StringComparison.OrdinalIgnoreCase)) return p.TryGetProperty("pk", out var pkProp) ? pkProp.GetString() : null; } } } catch (Exception ex) { _logger.LogDebug(ex, "[Authentik] Error checking for existing policy '{Name}'", name); } return null; } private async Task TryGetRolePkByNameAsync(HttpClient client, string name, CancellationToken ct) { try { var resp = await client.GetAsync($"/api/v3/rbac/roles/?search={Uri.EscapeDataString(name)}&page_size=50", ct); if (!resp.IsSuccessStatusCode) return null; var body = await resp.Content.ReadAsStringAsync(ct); using var doc = JsonDocument.Parse(body); if (doc.RootElement.TryGetProperty("results", out var results) && results.ValueKind == JsonValueKind.Array) { foreach (var r in results.EnumerateArray()) { var rName = r.TryGetProperty("name", out var nProp) ? nProp.GetString() : null; if (string.Equals(rName, name, StringComparison.OrdinalIgnoreCase)) return r.TryGetProperty("pk", out var pkProp) ? pkProp.GetString() : null; } } } catch (Exception ex) { _logger.LogDebug(ex, "[Authentik] Error checking for existing role '{Name}'", name); } return null; } private async Task> ResolvePermissionIdsAsync( HttpClient client, IEnumerable codenames, CancellationToken ct) { var ids = new List(); var codenameSet = new HashSet(codenames, StringComparer.OrdinalIgnoreCase); try { var resp = await client.GetAsync("/api/v3/rbac/permissions/?page_size=500", ct); if (!resp.IsSuccessStatusCode) { _logger.LogWarning("[Authentik] Could not list permissions (HTTP {Status})", (int)resp.StatusCode); return ids; } var body = await resp.Content.ReadAsStringAsync(ct); using var doc = JsonDocument.Parse(body); if (doc.RootElement.TryGetProperty("results", out var results) && results.ValueKind == JsonValueKind.Array) { foreach (var perm in results.EnumerateArray()) { var codename = perm.TryGetProperty("codename", out var cnProp) ? cnProp.GetString() : null; if (codename != null && codenameSet.Contains(codename)) { if (perm.TryGetProperty("id", out var idProp) && idProp.TryGetInt32(out var id)) ids.Add(id); } } } } catch (Exception ex) { _logger.LogWarning(ex, "[Authentik] Error resolving permission IDs"); } _logger.LogDebug("[Authentik] Resolved {Count}/{Total} permission ID(s)", ids.Count, codenameSet.Count); return ids; } // ───────────────────────────────────────────────────────────────────────── // 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; } } }