Refactor SAML configuration deployment and enhance Authentik integration

- Removed SAML configuration deployment calls from PostInstanceInitService.
- Updated DeploySamlConfigurationAsync to improve template fetching logic from Git and local directories.
- Added Authentik flow and keypair models for better representation in the UI.
- Enhanced SettingsViewModel to include Authentik settings with save and test functionality.
- Updated UI to support Authentik configuration, including dropdowns for flows and keypairs.
- Changed default CMS server name template to "app.ots-signs.com" across various files.
- Improved password handling in SshDockerCliService for secure shell command execution.
- Added new template file for settings-custom.php in the project structure.
This commit is contained in:
Matt Batchelder
2026-02-27 22:15:24 -05:00
parent 2aaa0442b2
commit 56d48b6062
22 changed files with 1245 additions and 172 deletions

View File

@@ -82,7 +82,7 @@ public class InstanceDefaultsOptions
public string? TemplateRepoPat { get; set; }
/// <summary>Template for CMS server hostname. Use {abbrev} as placeholder.</summary>
public string CmsServerNameTemplate { get; set; } = "{abbrev}.ots-signs.com";
public string CmsServerNameTemplate { get; set; } = "app.ots-signs.com";
public string SmtpServer { get; set; } = string.Empty;
public string SmtpUsername { get; set; } = string.Empty;

View File

@@ -0,0 +1,17 @@
namespace OTSSignsOrchestrator.Core.Models.DTOs;
/// <summary>
/// Represents an Authentik flow for display in the Settings UI.
/// </summary>
public class AuthentikFlowItem
{
public string Pk { get; set; } = string.Empty;
public string Slug { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string Designation { get; set; } = string.Empty;
/// <summary>Display text for ComboBox: "slug — Name".</summary>
public string DisplayText => $"{Slug} — {Name}";
public override string ToString() => DisplayText;
}

View File

@@ -0,0 +1,15 @@
namespace OTSSignsOrchestrator.Core.Models.DTOs;
/// <summary>
/// Represents an Authentik certificate keypair for display in the Settings UI.
/// </summary>
public class AuthentikKeypairItem
{
public string Pk { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
/// <summary>Display text for ComboBox.</summary>
public string DisplayText => $"{Name} ({Pk[..Math.Min(8, Pk.Length)]})";
public override string ToString() => DisplayText;
}

View File

@@ -1,5 +1,6 @@
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;
@@ -56,6 +57,7 @@ public class AuthentikService : IAuthentikService
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 ────────────────────────
@@ -78,15 +80,31 @@ public class AuthentikService : IAuthentikService
SettingsService.AuthentikInvalidationFlowSlug,
"default-provider-invalidation-flow", ct);
// ── 3. Create SAML provider ───────────────────────────────────
providerId = await CreateSamlProviderAsync(client, baseUrl,
instanceAbbrev, samlBaseUrl, authFlowUuid, invalidFlowUuid, 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, ct);
}
// ── 5. Fetch and parse metadata ───────────────────────────────────
// ── 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);
// ── 6. Fetch and parse metadata ───────────────────────────────────
var config = await FetchAndParseMetadataAsync(client, baseUrl, providerId, ct);
config.ApplicationSlug = slug;
@@ -97,14 +115,75 @@ public class AuthentikService : IAuthentikService
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();
}
// ─────────────────────────────────────────────────────────────────────────
// HTTP client setup
// ─────────────────────────────────────────────────────────────────────────
private async Task<(string BaseUrl, HttpClient Client)> CreateAuthenticatedClientAsync()
private async Task<(string BaseUrl, HttpClient Client)> CreateAuthenticatedClientAsync(
string? overrideUrl = null, string? overrideApiKey = null)
{
var authentikUrl = await _settings.GetAsync(SettingsService.AuthentikUrl);
var apiKey = await _settings.GetAsync(SettingsService.AuthentikApiKey);
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.");
@@ -133,10 +212,22 @@ public class AuthentikService : IAuthentikService
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 })
{
var app = json.Results[0];
return app.Provider;
_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)
@@ -147,6 +238,273 @@ public class AuthentikService : IAuthentikService
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);
}
}
// ─────────────────────────────────────────────────────────────────────────
// Flow resolution
// ─────────────────────────────────────────────────────────────────────────
@@ -168,15 +526,26 @@ public class AuthentikService : IAuthentikService
_logger.LogDebug("[Authentik] Resolving flow UUID for slug '{Slug}'", slug);
var resp = await client.GetAsync($"/api/v3/flows/instances/?slug={slug}", ct);
// 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);
if (json?.Results is not { Count: > 0 })
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 = json.Results[0].Pk;
var uuid = match.Pk;
// Cache for subsequent calls
if (settingsKey == SettingsService.AuthentikAuthorizationFlowSlug)
@@ -206,22 +575,48 @@ public class AuthentikService : IAuthentikService
["invalidation_flow"] = invalidFlowUuid,
["acs_url"] = $"{samlBaseUrl}/acs",
["sp_binding"] = "post",
["issuer"] = "authentik",
["issuer"] = $"ds-{abbrev}",
["audience"] = $"{samlBaseUrl}/metadata",
["default_relay_state"] = "",
["name_id_mapping"] = (object)null!, // use default
["sls_url"] = $"{samlBaseUrl}/sls",
["sls_binding"] = "redirect",
};
// Optionally add SLO URL
payload["sls_url"] = $"{samlBaseUrl}/sls";
payload["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");
// Optionally attach signing keypair
// 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 resp = await client.PostAsJsonAsync("/api/v3/providers/saml/", payload, ct);
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)
{
@@ -256,7 +651,11 @@ public class AuthentikService : IAuthentikService
["provider"] = providerId,
};
var resp = await client.PostAsJsonAsync("/api/v3/core/applications/", payload, ct);
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)
{
@@ -277,16 +676,215 @@ public class AuthentikService : IAuthentikService
{
_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"));
// 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);
resp.EnsureSuccessStatusCode();
var resp = await client.SendAsync(request, ct);
var xml = await resp.Content.ReadAsStringAsync(ct);
return ParseMetadataXml(xml, providerId);
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)
@@ -367,6 +965,30 @@ public class AuthentikService : IAuthentikService
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")]
@@ -399,4 +1021,19 @@ public class AuthentikService : IAuthentikService
[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; }
}
}

View File

@@ -246,8 +246,7 @@ public class ComposeRenderService
NEWT_ID: {{NEWT_ID}}
NEWT_SECRET: {{NEWT_SECRET}}
depends_on:
{{ABBREV}}-web:
condition: service_healthy
- {{ABBREV}}-web
networks:
{{ABBREV}}-net: {}
deploy:

View File

@@ -13,12 +13,33 @@ public interface IAuthentikService
/// 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);
/// <summary>
/// Tests the connection to Authentik by fetching the current user.
/// Optionally accepts override URL/key for testing before saving.
/// </summary>
Task<(bool Success, string Message)> TestConnectionAsync(
string? overrideUrl = null,
string? overrideApiKey = null,
CancellationToken ct = default);
/// <summary>
/// Returns all available flows from Authentik.
/// </summary>
Task<List<AuthentikFlowItem>> ListFlowsAsync(
string? overrideUrl = null,
string? overrideApiKey = null,
CancellationToken ct = default);
/// <summary>
/// Returns all certificate keypairs from Authentik.
/// </summary>
Task<List<AuthentikKeypairItem>> ListKeypairsAsync(
string? overrideUrl = null,
string? overrideApiKey = null,
CancellationToken ct = default);
}

View File

@@ -153,7 +153,7 @@ public class InstanceService
var mySqlDbName = (await _settings.GetAsync(SettingsService.DefaultMySqlDbTemplate, "{abbrev}_cms_db")).Replace("{abbrev}", abbrev);
var mySqlUser = mySqlUserName;
var cmsServerName = (await _settings.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "{abbrev}.ots-signs.com")).Replace("{abbrev}", abbrev);
var cmsServerName = (await _settings.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "app.ots-signs.com")).Replace("{abbrev}", abbrev);
var themePath = (await _settings.GetAsync(SettingsService.DefaultThemeHostPath, "/cms/ots-theme")).Replace("{abbrev}", abbrev);
var smtpServer = await _settings.GetAsync(SettingsService.SmtpServer, string.Empty);
@@ -249,6 +249,12 @@ public class InstanceService
+ "(2) NFS export has root_squash enabled — set 'No mapping' / no_root_squash on the NFS server.");
}
// ── 5c. Write settings-custom.php to NFS volume (SAML config) ────────
// This must happen before the stack is deployed so Xibo starts with SAML
// authentication already configured.
var instanceUrlForSaml = $"https://{cmsServerName}/{abbrev}";
await _postInit.DeploySamlConfigurationAsync(abbrev, instanceUrlForSaml, _settings, default);
// ── 6. Deploy stack ─────────────────────────────────────────────
var deployResult = await _docker.DeployStackAsync(stackName, composeYaml);
if (!deployResult.Success)
@@ -341,7 +347,7 @@ public class InstanceService
var pangolinEndpoint = await _settings.GetAsync(SettingsService.PangolinEndpoint, "https://app.pangolin.net");
var cmsServerName = dto.CmsServerName
?? (await _settings.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "{abbrev}.ots-signs.com")).Replace("{abbrev}", abbrev);
?? (await _settings.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "app.ots-signs.com")).Replace("{abbrev}", abbrev);
var hostHttpPort = dto.HostHttpPort ?? 80;
var themePath = dto.ThemeHostPath
?? (await _settings.GetAsync(SettingsService.DefaultThemeHostPath, "/cms/ots-theme")).Replace("{abbrev}", abbrev);

View File

@@ -107,9 +107,6 @@ 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");
@@ -200,9 +197,6 @@ 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(
@@ -349,9 +343,11 @@ public class PostInstanceInitService
/// <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.
/// The template is resolved from (a) the git repo cache, or (b) the local bundled
/// <c>templates/</c> directory shipped with the application.
/// Errors are logged but do not fail the overall deployment.
/// </summary>
private async Task DeploySamlConfigurationAsync(
public async Task DeploySamlConfigurationAsync(
string abbrev,
string instanceUrl,
SettingsService settings,
@@ -366,36 +362,80 @@ public class PostInstanceInitService
var git = scope.ServiceProvider.GetRequiredService<GitTemplateService>();
var docker = scope.ServiceProvider.GetRequiredService<IDockerCliService>();
// ── 1. Fetch template from git repo ───────────────────────────────
// ── 1. Locate settings-custom.php.template ────────────────────────
string? templateContent = null;
// 1a. Try git repo cache first
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.");
if (!string.IsNullOrWhiteSpace(repoUrl))
{
try
{
var templateConfig = await git.FetchAsync(repoUrl, repoPat);
var gitPath = Path.Combine(templateConfig.CacheDir, "settings-custom.php.template");
if (File.Exists(gitPath))
{
templateContent = await File.ReadAllTextAsync(gitPath, ct);
_logger.LogInformation("[PostInit] Using template from git repo cache: {Path}", gitPath);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[PostInit] Could not fetch template from git — trying local fallback");
}
}
var templateConfig = await git.FetchAsync(repoUrl, repoPat);
var templatePath = Path.Combine(templateConfig.CacheDir, "settings-custom.php.template");
// 1b. Fall back to local templates/ directory (bundled with app)
if (templateContent == null)
{
var candidates = new[]
{
Path.Combine(AppContext.BaseDirectory, "templates", "settings-custom.php.template"),
Path.Combine(Directory.GetCurrentDirectory(), "templates", "settings-custom.php.template"),
};
if (!File.Exists(templatePath))
foreach (var candidate in candidates)
{
if (File.Exists(candidate))
{
templateContent = await File.ReadAllTextAsync(candidate, ct);
_logger.LogInformation("[PostInit] Using local template: {Path}", candidate);
break;
}
}
}
if (templateContent == null)
{
_logger.LogWarning(
"[PostInit] settings-custom.php.template not found in git repo — skipping SAML deployment");
"[PostInit] settings-custom.php.template not found in git repo or local templates/ — 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);
Models.DTOs.AuthentikSamlConfig? samlConfig = null;
try
{
samlConfig = await authentik.ProvisionSamlAsync(abbrev, instanceUrl, ct);
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"[PostInit] Authentik provisioning failed for {Abbrev} — skipping SAML config deployment to avoid broken Xibo instance",
abbrev);
return; // Do NOT write a settings-custom.php with empty IdP values — it will crash Xibo
}
// ── 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);
.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);
@@ -415,8 +455,11 @@ public class PostInstanceInitService
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);
"[PostInit] SAML configuration deployed for {Abbrev}{ProviderInfo}",
abbrev,
samlConfig != null
? $" (Authentik provider={samlConfig.ProviderId})"
: " (without Authentik — needs manual IdP config)");
}
catch (Exception ex)
{