Files
OTSSignsOrchestrator/OTSSignsOrchestrator.Core/Services/AuthentikService.cs
Matt Batchelder 150549a20d feat: Implement customer invitation infrastructure in Authentik
- Added IInvitationSetupService and InvitationSetupService to orchestrate the setup of invitation infrastructure for customers.
- Introduced methods for creating groups, enrollment flows, invitation stages, roles, and policies in Authentik.
- Updated PostInstanceInitService to call the new invitation setup methods during post-initialization.
- Enhanced InstanceService to pass customer name during SAML configuration deployment.
- Updated App.axaml.cs to register the new IInvitationSetupService.
- Modified settings-custom.php.template to include documentation for SAML authentication configuration with group-based admin assignment.
- Added logic to exclude specific groups from being synced to Xibo during group synchronization.
2026-03-04 21:58:59 -05:00

1927 lines
86 KiB
C#

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;
/// <summary>
/// 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.
/// </summary>
public class AuthentikService : IAuthentikService
{
private readonly IHttpClientFactory _httpFactory;
private readonly SettingsService _settings;
private readonly ILogger<AuthentikService> _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<AuthentikService> logger)
{
_httpFactory = httpFactory;
_settings = settings;
_logger = logger;
}
// ─────────────────────────────────────────────────────────────────────────
// Public API
// ─────────────────────────────────────────────────────────────────────────
public async Task<AuthentikSamlConfig> 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
// ─────────────────────────────────────────────────────────────────────────
/// <inheritdoc />
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);
}
}
/// <inheritdoc />
public async Task<List<AuthentikFlowItem>> 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<AuthentikListResponse<AuthentikFlowDetailed>>(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();
}
/// <inheritdoc />
public async Task<List<AuthentikKeypairItem>> 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<AuthentikListResponse<AuthentikKeypairDetailed>>(cancellationToken: ct);
return json?.Results?.Select(k => new AuthentikKeypairItem
{
Pk = k.Pk,
Name = k.Name,
}).OrderBy(k => k.Name).ToList() ?? new();
}
/// <inheritdoc />
public async Task<List<AuthentikGroupItem>> 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<AuthentikGroupItem>();
}
var groups = new List<AuthentikGroupItem>();
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<int?> 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<AuthentikListResponse<AuthentikApplication>>(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<int?> 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<AuthentikListResponse<AuthentikSamlProvider>>(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
// ─────────────────────────────────────────────────────────────────────────
/// <summary>
/// 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.
/// </summary>
private async Task<string?> 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<AuthentikListResponse<AuthentikKeypairDetailed>>(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
// ─────────────────────────────────────────────────────────────────────────
/// <summary>
/// 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 &lt;Attribute&gt; elements inside the &lt;AttributeStatement&gt;.
/// Without at least one mapping, Authentik sends an empty AttributeStatement
/// which fails strict XML schema validation in php-saml / Xibo.
/// </summary>
private async Task<List<string>> 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<AuthentikListResponse<AuthentikPropertyMapping>>(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<string>();
}
// ─────────────────────────────────────────────────────────────────────────
// Ensure provider has a signing keypair
// ─────────────────────────────────────────────────────────────────────────
/// <summary>
/// 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.
/// </summary>
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<string, object>
{
["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);
}
}
/// <summary>
/// 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 &lt;AttributeStatement&gt; which fails
/// strict XML schema validation in php-saml / Xibo.
/// </summary>
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<string, object>
{
["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);
}
}
/// <summary>
/// Ensures that the SAML property mapping for <c>usertypeid</c> (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.
/// </summary>
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);
}
}
/// <summary>
/// Looks for an existing SAML property mapping named "saml-usertypeid".
/// Returns its ID if found, null otherwise.
/// </summary>
private async Task<string?> 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<AuthentikListResponse<AuthentikPropertyMapping>>(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;
}
/// <summary>
/// Creates a new SAML property mapping named "saml-usertypeid" that returns "1"
/// for users in the "OTS IT" group and empty string otherwise.
/// </summary>
private async Task<string?> 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<string, object>
{
["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<AuthentikPropertyMapping>(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;
}
}
/// <summary>
/// Attaches the usertypeid property mapping to the given SAML provider
/// so it's included in outgoing SAML assertions.
/// </summary>
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<string>();
// ── 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<string, object>
{
["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<string> 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<AuthentikListResponse<AuthentikFlow>>(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<string>();
_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<int> 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<string, object>
{
["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
// <AttributeStatement> 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<AuthentikSamlProvider>(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<string, object>
{
["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<AuthentikSamlConfig> 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": "<xml>", "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);
}
/// <summary>
/// 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 <c>/metadata/</c> endpoint returns an error.
/// </summary>
private async Task<AuthentikSamlConfig> 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;
}
/// <summary>
/// Fetches the PEM certificate from Authentik's keypair endpoints and
/// returns the base64-encoded X.509 body (no PEM headers).
/// Tries <c>/view_certificate/</c> first, then falls back to the regular
/// keypair detail and <c>certificate_data</c>.
/// </summary>
private async Task<string> 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<string> 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
// ─────────────────────────────────────────────────────────────────────────
/// <inheritdoc />
public async Task<string> 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<string, object> { ["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;
}
/// <inheritdoc />
public async Task<string> 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<string, object>
{
["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;
}
/// <inheritdoc />
public async Task<string> 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<string, object>
{
["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;
}
/// <inheritdoc />
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<string, object>
{
["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);
}
/// <inheritdoc />
public async Task<string> 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<string, object>
{
["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;
}
/// <inheritdoc />
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<string, object>
{
["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");
}
/// <inheritdoc />
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<string, object>
{
["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);
}
/// <inheritdoc />
public async Task<string> 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<string, object> { ["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;
}
/// <inheritdoc />
public async Task AssignPermissionsToRoleAsync(
string rolePk, IEnumerable<string> 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<string, object>
{
["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<string, object>
{
["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);
}
/// <inheritdoc />
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<string>();
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<string, object>
{
["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);
}
/// <inheritdoc />
public async Task<string?> 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;
}
/// <inheritdoc />
public async Task<string?> 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<string?> 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<string?> 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<string?> 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<string?> 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<List<int>> ResolvePermissionIdsAsync(
HttpClient client, IEnumerable<string> codenames, CancellationToken ct)
{
var ids = new List<int>();
var codenameSet = new HashSet<string>(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<T>
{
[JsonPropertyName("pagination")]
public object? Pagination { get; set; }
[JsonPropertyName("results")]
public List<T> 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;
/// <summary>
/// Non-null for built-in managed mappings, e.g. "goauthentik.io/providers/saml/upn".
/// </summary>
[JsonPropertyName("managed")]
public string? Managed { get; set; }
}
}