403 lines
19 KiB
C#
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; }
|
||
|
|
}
|
||
|
|
}
|