work with authentik
This commit is contained in:
402
OTSSignsOrchestrator.Core/Services/AuthentikService.cs
Normal file
402
OTSSignsOrchestrator.Core/Services/AuthentikService.cs
Normal file
@@ -0,0 +1,402 @@
|
||||
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; }
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,8 @@ public class GitTemplateService
|
||||
return new TemplateConfig
|
||||
{
|
||||
Yaml = yaml,
|
||||
FetchedAt = DateTime.UtcNow
|
||||
FetchedAt = DateTime.UtcNow,
|
||||
CacheDir = cacheDir,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
24
OTSSignsOrchestrator.Core/Services/IAuthentikService.cs
Normal file
24
OTSSignsOrchestrator.Core/Services/IAuthentikService.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using OTSSignsOrchestrator.Core.Models.DTOs;
|
||||
|
||||
namespace OTSSignsOrchestrator.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Provisions SAML applications in Authentik and retrieves IdP metadata
|
||||
/// needed to render the Xibo SAML settings-custom.php template.
|
||||
/// </summary>
|
||||
public interface IAuthentikService
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates an Authentik SAML provider and application for the given Xibo instance,
|
||||
/// then fetches the IdP metadata (entity ID, x509 cert, SSO/SLO URLs).
|
||||
/// If the application already exists (by slug), returns its existing metadata.
|
||||
/// </summary>
|
||||
/// <param name="instanceAbbrev">Short customer abbreviation (used in naming).</param>
|
||||
/// <param name="instanceBaseUrl">Full base URL of the Xibo instance (e.g. https://app.ots-signs.com/demo).</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>IdP metadata needed for the SAML PHP configuration.</returns>
|
||||
Task<AuthentikSamlConfig> ProvisionSamlAsync(
|
||||
string instanceAbbrev,
|
||||
string instanceBaseUrl,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@@ -97,6 +97,22 @@ public interface IDockerCliService
|
||||
/// Returns parsed log entries sorted by timestamp ascending.
|
||||
/// </summary>
|
||||
Task<List<ServiceLogEntry>> GetServiceLogsAsync(string stackName, string? serviceName = null, int tailLines = 200);
|
||||
|
||||
/// <summary>
|
||||
/// Writes a file to an NFS volume by temporarily mounting the export on the Docker host.
|
||||
/// Used to deploy configuration files (e.g. settings-custom.php) into CMS containers.
|
||||
/// </summary>
|
||||
/// <param name="nfsServer">NFS server hostname or IP.</param>
|
||||
/// <param name="nfsExport">NFS export path (e.g. "/srv/nfs").</param>
|
||||
/// <param name="relativePath">Path relative to the export root (e.g. "subfolder/abbrev/cms-custom/settings-custom.php").</param>
|
||||
/// <param name="content">File content to write.</param>
|
||||
/// <param name="nfsExportFolder">Optional subfolder within the export.</param>
|
||||
Task<(bool Success, string? Error)> WriteFileToNfsAsync(
|
||||
string nfsServer,
|
||||
string nfsExport,
|
||||
string relativePath,
|
||||
string content,
|
||||
string? nfsExportFolder = null);
|
||||
}
|
||||
|
||||
public class StackInfo
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Security.Cryptography;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OTSSignsOrchestrator.Core.Configuration;
|
||||
|
||||
namespace OTSSignsOrchestrator.Core.Services;
|
||||
|
||||
@@ -106,6 +107,9 @@ public class PostInstanceInitService
|
||||
_logger.LogInformation("[PostInit] Setting Xibo theme to 'otssigns'");
|
||||
await xibo.SetThemeAsync(instanceUrl, accessToken, "otssigns");
|
||||
|
||||
// ── 6a. Deploy SAML configuration ─────────────────────────────────
|
||||
await DeploySamlConfigurationAsync(abbrev, instanceUrl, settings, ct);
|
||||
|
||||
// ── 7. Store credentials in Bitwarden ─────────────────────────────
|
||||
_logger.LogInformation("[PostInit] Storing credentials in Bitwarden");
|
||||
|
||||
@@ -196,6 +200,9 @@ public class PostInstanceInitService
|
||||
_logger.LogInformation("[PostInit] Setting theme to 'otssigns'");
|
||||
await xibo.SetThemeAsync(instanceUrl, accessToken, "otssigns");
|
||||
|
||||
// ── 5a. Deploy SAML configuration ─────────────────────────────────
|
||||
await DeploySamlConfigurationAsync(abbrev, instanceUrl, settings, ct);
|
||||
|
||||
// ── 6. Store admin password in Bitwarden ──────────────────────────
|
||||
_logger.LogInformation("[PostInit] Storing credentials in Bitwarden");
|
||||
var adminSecretId = await bws.CreateInstanceSecretAsync(
|
||||
@@ -335,6 +342,90 @@ public class PostInstanceInitService
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// SAML configuration deployment
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Provisions a SAML application in Authentik, renders the settings-custom.php template,
|
||||
/// and writes the rendered file to the instance's NFS-backed cms-custom volume.
|
||||
/// Errors are logged but do not fail the overall post-init process.
|
||||
/// </summary>
|
||||
private async Task DeploySamlConfigurationAsync(
|
||||
string abbrev,
|
||||
string instanceUrl,
|
||||
SettingsService settings,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("[PostInit] Deploying SAML settings-custom.php for {Abbrev}", abbrev);
|
||||
|
||||
using var scope = _services.CreateScope();
|
||||
var authentik = scope.ServiceProvider.GetRequiredService<IAuthentikService>();
|
||||
var git = scope.ServiceProvider.GetRequiredService<GitTemplateService>();
|
||||
var docker = scope.ServiceProvider.GetRequiredService<IDockerCliService>();
|
||||
|
||||
// ── 1. Fetch template from git repo ───────────────────────────────
|
||||
var repoUrl = await settings.GetAsync(SettingsService.GitRepoUrl);
|
||||
var repoPat = await settings.GetAsync(SettingsService.GitRepoPat);
|
||||
if (string.IsNullOrWhiteSpace(repoUrl))
|
||||
throw new InvalidOperationException("Git repository URL is not configured.");
|
||||
|
||||
var templateConfig = await git.FetchAsync(repoUrl, repoPat);
|
||||
var templatePath = Path.Combine(templateConfig.CacheDir, "settings-custom.php.template");
|
||||
|
||||
if (!File.Exists(templatePath))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"[PostInit] settings-custom.php.template not found in git repo — skipping SAML deployment");
|
||||
return;
|
||||
}
|
||||
|
||||
var templateContent = await File.ReadAllTextAsync(templatePath, ct);
|
||||
|
||||
// ── 2. Provision Authentik SAML application ───────────────────────
|
||||
var samlBaseUrl = instanceUrl.TrimEnd('/') + "/saml";
|
||||
var samlConfig = await authentik.ProvisionSamlAsync(abbrev, instanceUrl, ct);
|
||||
|
||||
// ── 3. Render template ────────────────────────────────────────────
|
||||
var rendered = templateContent
|
||||
.Replace("{{SAML_BASE_URL}}", samlBaseUrl)
|
||||
.Replace("{{SAML_SP_ENTITY_ID}}", $"{samlBaseUrl}/metadata")
|
||||
.Replace("{{AUTHENTIK_IDP_ENTITY_ID}}", samlConfig.IdpEntityId)
|
||||
.Replace("{{AUTHENTIK_SSO_URL}}", samlConfig.SsoUrlRedirect)
|
||||
.Replace("{{AUTHENTIK_SLO_URL}}", samlConfig.SloUrlRedirect)
|
||||
.Replace("{{AUTHENTIK_IDP_X509_CERT}}", samlConfig.IdpX509Cert);
|
||||
|
||||
// ── 4. Write rendered file to NFS volume ──────────────────────────
|
||||
var nfsServer = await settings.GetAsync(SettingsService.NfsServer);
|
||||
var nfsExport = await settings.GetAsync(SettingsService.NfsExport);
|
||||
var nfsExportFolder = await settings.GetAsync(SettingsService.NfsExportFolder);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(nfsServer) || string.IsNullOrWhiteSpace(nfsExport))
|
||||
throw new InvalidOperationException("NFS settings are not configured — cannot write SAML config to volume.");
|
||||
|
||||
// Path within the NFS export: {abbrev}/cms-custom/settings-custom.php
|
||||
var nfsRelativePath = $"{abbrev}/cms-custom/settings-custom.php";
|
||||
|
||||
var (success, error) = await docker.WriteFileToNfsAsync(
|
||||
nfsServer, nfsExport, nfsRelativePath, rendered, nfsExportFolder);
|
||||
|
||||
if (!success)
|
||||
throw new InvalidOperationException($"Failed to write settings-custom.php to NFS: {error}");
|
||||
|
||||
_logger.LogInformation(
|
||||
"[PostInit] SAML configuration deployed for {Abbrev} (Authentik provider={ProviderId})",
|
||||
abbrev, samlConfig.ProviderId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[PostInit] SAML deployment failed for {Abbrev}: {Message}. " +
|
||||
"Instance will continue without SAML — configure manually if needed.", abbrev, ex.Message);
|
||||
// Don't rethrow — SAML failure should not block the rest of post-init
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -22,6 +22,7 @@ public class SettingsService
|
||||
public const string CatPangolin = "Pangolin";
|
||||
public const string CatNfs = "Nfs";
|
||||
public const string CatDefaults = "Defaults";
|
||||
public const string CatAuthentik = "Authentik";
|
||||
|
||||
// ── Key constants ──────────────────────────────────────────────────────
|
||||
// Git
|
||||
@@ -72,6 +73,13 @@ public class SettingsService
|
||||
public const string XiboBootstrapClientId = "Xibo.BootstrapClientId";
|
||||
public const string XiboBootstrapClientSecret = "Xibo.BootstrapClientSecret";
|
||||
|
||||
// Authentik (SAML IdP provisioning)
|
||||
public const string AuthentikUrl = "Authentik.Url";
|
||||
public const string AuthentikApiKey = "Authentik.ApiKey";
|
||||
public const string AuthentikAuthorizationFlowSlug = "Authentik.AuthorizationFlowSlug";
|
||||
public const string AuthentikInvalidationFlowSlug = "Authentik.InvalidationFlowSlug";
|
||||
public const string AuthentikSigningKeypairId = "Authentik.SigningKeypairId";
|
||||
|
||||
// Instance-specific (keyed by abbreviation)
|
||||
/// <summary>
|
||||
/// Builds a per-instance settings key for the MySQL password.
|
||||
|
||||
Reference in New Issue
Block a user