Files
OTSSignsOrchestrator/OTSSignsOrchestrator.Core/Services/AuthentikService.cs
2026-02-27 17:48:21 -05:00

403 lines
19 KiB
C#

using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Xml.Linq;
using Microsoft.Extensions.Logging;
using OTSSignsOrchestrator.Core.Models.DTOs;
namespace OTSSignsOrchestrator.Core.Services;
/// <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 samlBaseUrl = instanceBaseUrl.TrimEnd('/') + "/saml";
// ── 1. Check if application already exists ────────────────────────
var existingProviderId = await TryGetExistingProviderIdAsync(client, baseUrl, slug, ct);
int providerId;
if (existingProviderId.HasValue)
{
_logger.LogInformation("[Authentik] Application '{Slug}' already exists (provider={Id}), reusing", slug, existingProviderId.Value);
providerId = existingProviderId.Value;
}
else
{
// ── 2. Resolve flow UUIDs ─────────────────────────────────────
var authFlowUuid = await ResolveFlowUuidAsync(client, baseUrl,
SettingsService.AuthentikAuthorizationFlowSlug,
"default-provider-authorization-implicit-consent", ct);
var invalidFlowUuid = await ResolveFlowUuidAsync(client, baseUrl,
SettingsService.AuthentikInvalidationFlowSlug,
"default-provider-invalidation-flow", ct);
// ── 3. Create SAML provider ───────────────────────────────────
providerId = await CreateSamlProviderAsync(client, baseUrl,
instanceAbbrev, samlBaseUrl, authFlowUuid, invalidFlowUuid, ct);
// ── 4. Create application linked to provider ──────────────────
await CreateApplicationAsync(client, baseUrl, instanceAbbrev, slug, providerId, ct);
}
// ── 5. Fetch and parse metadata ───────────────────────────────────
var config = await FetchAndParseMetadataAsync(client, baseUrl, providerId, ct);
config.ApplicationSlug = slug;
_logger.LogInformation(
"[Authentik] SAML provisioned for {Abbrev}: provider={ProviderId}, entityId={EntityId}",
instanceAbbrev, config.ProviderId, config.IdpEntityId);
return config;
}
// ─────────────────────────────────────────────────────────────────────────
// HTTP client setup
// ─────────────────────────────────────────────────────────────────────────
private async Task<(string BaseUrl, HttpClient Client)> CreateAuthenticatedClientAsync()
{
var authentikUrl = await _settings.GetAsync(SettingsService.AuthentikUrl);
var apiKey = await _settings.GetAsync(SettingsService.AuthentikApiKey);
if (string.IsNullOrWhiteSpace(authentikUrl))
throw new InvalidOperationException("Authentik URL is not configured. Set it in Settings → Authentik.");
if (string.IsNullOrWhiteSpace(apiKey))
throw new InvalidOperationException("Authentik API Key is not configured. Set it in Settings → Authentik.");
var baseUrl = authentikUrl.TrimEnd('/');
var client = _httpFactory.CreateClient("AuthentikApi");
client.BaseAddress = new Uri(baseUrl);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
return (baseUrl, client);
}
// ─────────────────────────────────────────────────────────────────────────
// Check for existing application
// ─────────────────────────────────────────────────────────────────────────
private async Task<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);
if (json?.Results is { Count: > 0 })
{
var app = json.Results[0];
return app.Provider;
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "[Authentik] Could not check for existing application '{Slug}'", slug);
}
return null;
}
// ─────────────────────────────────────────────────────────────────────────
// Flow resolution
// ─────────────────────────────────────────────────────────────────────────
private async Task<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);
var resp = await client.GetAsync($"/api/v3/flows/instances/?slug={slug}", ct);
resp.EnsureSuccessStatusCode();
var json = await resp.Content.ReadFromJsonAsync<AuthentikListResponse<AuthentikFlow>>(cancellationToken: ct);
if (json?.Results is not { Count: > 0 })
throw new InvalidOperationException(
$"Authentik flow '{slug}' not found. Ensure the flow exists or configure the correct slug in Settings → Authentik.");
var uuid = json.Results[0].Pk;
// Cache for subsequent calls
if (settingsKey == SettingsService.AuthentikAuthorizationFlowSlug)
_cachedAuthorizationFlowUuid = uuid;
else if (settingsKey == SettingsService.AuthentikInvalidationFlowSlug)
_cachedInvalidationFlowUuid = uuid;
return uuid;
}
// ─────────────────────────────────────────────────────────────────────────
// SAML Provider creation
// ─────────────────────────────────────────────────────────────────────────
private async Task<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"] = "authentik",
["audience"] = $"{samlBaseUrl}/metadata",
["default_relay_state"] = "",
["name_id_mapping"] = (object)null!, // use default
};
// Optionally add SLO URL
payload["sls_url"] = $"{samlBaseUrl}/sls";
payload["sls_binding"] = "redirect";
// Optionally attach signing keypair
var signingKpId = await _settings.GetAsync(SettingsService.AuthentikSigningKeypairId);
if (!string.IsNullOrWhiteSpace(signingKpId))
payload["signing_kp"] = signingKpId;
var resp = await client.PostAsJsonAsync("/api/v3/providers/saml/", payload, ct);
if (!resp.IsSuccessStatusCode)
{
var errorBody = await resp.Content.ReadAsStringAsync(ct);
throw new InvalidOperationException(
$"Failed to create Authentik SAML provider (HTTP {(int)resp.StatusCode}): {errorBody}");
}
var result = await resp.Content.ReadFromJsonAsync<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,
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,
};
var resp = await client.PostAsJsonAsync("/api/v3/core/applications/", payload, ct);
if (!resp.IsSuccessStatusCode)
{
var errorBody = await resp.Content.ReadAsStringAsync(ct);
throw new InvalidOperationException(
$"Failed to create Authentik application '{slug}' (HTTP {(int)resp.StatusCode}): {errorBody}");
}
_logger.LogInformation("[Authentik] Application '{Slug}' created", slug);
}
// ─────────────────────────────────────────────────────────────────────────
// Metadata retrieval & parsing
// ─────────────────────────────────────────────────────────────────────────
private async Task<AuthentikSamlConfig> FetchAndParseMetadataAsync(
HttpClient client, string baseUrl, int providerId, CancellationToken ct)
{
_logger.LogDebug("[Authentik] Fetching SAML metadata for provider {ProviderId}", providerId);
// Request XML metadata (override Accept header for this call)
var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v3/providers/saml/{providerId}/metadata/?download");
request.Headers.Accept.Clear();
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/xml"));
var resp = await client.SendAsync(request, ct);
resp.EnsureSuccessStatusCode();
var xml = await resp.Content.ReadAsStringAsync(ct);
return ParseMetadataXml(xml, providerId);
}
private AuthentikSamlConfig ParseMetadataXml(string xml, int providerId)
{
var doc = XDocument.Parse(xml);
var root = doc.Root
?? throw new InvalidOperationException("SAML metadata XML has no root element.");
var config = new AuthentikSamlConfig
{
ProviderId = providerId,
IdpEntityId = root.Attribute("entityID")?.Value ?? "authentik",
};
// Extract x509 certificate (signing key)
var certElement = root.Descendants(Ds + "X509Certificate").FirstOrDefault();
if (certElement != null)
{
// Remove whitespace/newlines from cert — some SAML libs expect a single line
config.IdpX509Cert = certElement.Value.Replace("\n", "").Replace("\r", "").Trim();
}
// Extract SSO URLs
foreach (var sso in root.Descendants(Md + "SingleSignOnService"))
{
var binding = sso.Attribute("Binding")?.Value;
var location = sso.Attribute("Location")?.Value;
if (location == null) continue;
if (binding?.Contains("HTTP-Redirect") == true)
config.SsoUrlRedirect = location;
else if (binding?.Contains("HTTP-POST") == true)
config.SsoUrlPost = location;
}
// Extract SLO URLs
foreach (var slo in root.Descendants(Md + "SingleLogoutService"))
{
var binding = slo.Attribute("Binding")?.Value;
var location = slo.Attribute("Location")?.Value;
if (location == null) continue;
if (binding?.Contains("HTTP-Redirect") == true)
config.SloUrlRedirect = location;
else if (binding?.Contains("HTTP-POST") == true)
config.SloUrlPost = location;
}
_logger.LogDebug(
"[Authentik] Metadata parsed: entityId={EntityId}, ssoRedirect={SsoUrl}, sloRedirect={SloUrl}, certLen={CertLen}",
config.IdpEntityId, config.SsoUrlRedirect, config.SloUrlRedirect, config.IdpX509Cert.Length);
return config;
}
// ─────────────────────────────────────────────────────────────────────────
// API response DTOs (internal)
// ─────────────────────────────────────────────────────────────────────────
private class AuthentikListResponse<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 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; }
}
}