work with authentik

This commit is contained in:
Matt Batchelder
2026-02-27 17:48:21 -05:00
parent 90eb649940
commit 2aaa0442b2
13 changed files with 699 additions and 2 deletions

Submodule .template-cache/2dc03e2b2b45fef3 updated: 07ab87bc65...a6ab3c254b

View File

@@ -0,0 +1,32 @@
namespace OTSSignsOrchestrator.Core.Models.DTOs;
/// <summary>
/// Holds the IdP metadata extracted from an Authentik SAML provider,
/// used to render the settings-custom.php template.
/// </summary>
public class AuthentikSamlConfig
{
/// <summary>IdP entity ID from SAML metadata (typically "authentik").</summary>
public string IdpEntityId { get; set; } = string.Empty;
/// <summary>Base64-encoded X.509 signing certificate (no BEGIN/END markers).</summary>
public string IdpX509Cert { get; set; } = string.Empty;
/// <summary>IdP Single Sign-On URL (HTTP-Redirect binding).</summary>
public string SsoUrlRedirect { get; set; } = string.Empty;
/// <summary>IdP Single Sign-On URL (HTTP-POST binding).</summary>
public string SsoUrlPost { get; set; } = string.Empty;
/// <summary>IdP Single Logout URL (HTTP-Redirect binding).</summary>
public string SloUrlRedirect { get; set; } = string.Empty;
/// <summary>IdP Single Logout URL (HTTP-POST binding).</summary>
public string SloUrlPost { get; set; } = string.Empty;
/// <summary>Authentik provider primary key (for audit/debugging).</summary>
public int ProviderId { get; set; }
/// <summary>Slug used in Authentik application URLs.</summary>
public string ApplicationSlug { get; set; } = string.Empty;
}

View File

@@ -4,4 +4,10 @@ public class TemplateConfig
{
public string Yaml { get; set; } = string.Empty;
public DateTime FetchedAt { get; set; } = DateTime.UtcNow;
/// <summary>
/// Local filesystem path to the cached git clone.
/// Used to access additional template files (e.g. settings-custom.php.template).
/// </summary>
public string CacheDir { get; set; } = string.Empty;
}

View 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; }
}
}

View File

@@ -56,7 +56,8 @@ public class GitTemplateService
return new TemplateConfig
{
Yaml = yaml,
FetchedAt = DateTime.UtcNow
FetchedAt = DateTime.UtcNow,
CacheDir = cacheDir,
};
}

View 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);
}

View File

@@ -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

View File

@@ -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
// ─────────────────────────────────────────────────────────────────────────

View File

@@ -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.

View File

@@ -138,6 +138,7 @@ public class App : Application
services.AddHttpClient();
services.AddHttpClient("XiboApi");
services.AddHttpClient("XiboHealth");
services.AddHttpClient("AuthentikApi");
// SSH services (singletons — maintain connections)
services.AddSingleton<SshConnectionService>();
@@ -156,6 +157,7 @@ public class App : Application
services.AddTransient<XiboApiService>();
services.AddTransient<InstanceService>();
services.AddTransient<IBitwardenSecretService, BitwardenSecretService>();
services.AddTransient<IAuthentikService, AuthentikService>();
services.AddSingleton<PostInstanceInitService>();
// ViewModels

View File

@@ -259,6 +259,58 @@ public class SshDockerCliService : IDockerCliService
return (false, error);
}
public async Task<(bool Success, string? Error)> WriteFileToNfsAsync(
string nfsServer,
string nfsExport,
string relativePath,
string content,
string? nfsExportFolder = null)
{
EnsureHost();
var exportPath = (nfsExport ?? string.Empty).Trim('/');
var subFolder = (nfsExportFolder ?? string.Empty).Trim('/');
var subPath = string.IsNullOrEmpty(subFolder) ? string.Empty : $"/{subFolder}";
// Ensure parent directory exists, then write content via heredoc
var targetPath = $"$MNT{subPath}/{relativePath.TrimStart('/')}";
var parentDir = $"$(dirname \"{targetPath}\")";
// Escape content for heredoc (replace any literal EOF that might appear in content)
var safeContent = content.Replace("'", "'\\''");
var script = $"""
set -e
MNT=$(mktemp -d)
sudo mount -t nfs -o addr={nfsServer},nfsvers=4,proto=tcp,soft,timeo=50,retrans=2 {nfsServer}:/{exportPath} "$MNT"
sudo mkdir -p {parentDir}
sudo tee "{targetPath}" > /dev/null << 'OTSSIGNS_EOF'
{content}
OTSSIGNS_EOF
sudo umount "$MNT"
rmdir "$MNT"
""";
_logger.LogInformation(
"Writing file to NFS {Server}:/{Export}{Sub}/{Path} on Docker host {Host}",
nfsServer, exportPath, subPath, relativePath, _currentHost!.Label);
var (exitCode, stdout, stderr) = await _ssh.RunCommandAsync(_currentHost!, script, TimeSpan.FromSeconds(30));
if (exitCode == 0)
{
_logger.LogInformation(
"File written to NFS on {Host}: {Server}:/{Export}{Sub}/{Path}",
_currentHost.Label, nfsServer, exportPath, subPath, relativePath);
return (true, null);
}
var error = (stderr ?? stdout ?? "unknown error").Trim();
_logger.LogWarning(
"Failed to write file to NFS on {Host}: {Error}",
_currentHost.Label, error);
return (false, error);
}
public async Task<bool> ForceUpdateServiceAsync(string serviceName)
{
EnsureHost();

View File

@@ -0,0 +1,63 @@
<?php
$authentication = new \Xibo\Middleware\SAMLAuthentication();
$samlSettings = [
'workflow' => [
'jit' => true,
'field_to_identify' => 'UserName',
'libraryQuota' => 1000,
'homePage' => 'icondashboard.view',
'slo' => true,
'mapping' => [
'UserID' => '',
'usertypeid' => '',
'UserName' => 'http://schemas.goauthentik.io/2021/02/saml/username',
'email' => 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress',
],
'group' => 'Users',
'matchGroups' => [
'enabled' => false,
'attribute' => null,
'extractionRegEx' => null,
],
],
'strict' => true,
'debug' => true,
'baseurl' => '{{SAML_BASE_URL}}',
'idp' => [
'entityId' => '{{AUTHENTIK_IDP_ENTITY_ID}}',
'singleSignOnService' => [
'url' => '{{AUTHENTIK_SSO_URL}}',
'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
],
'singleLogoutService' => [
'url' => '{{AUTHENTIK_SLO_URL}}',
'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
],
'x509cert' => '{{AUTHENTIK_IDP_X509_CERT}}',
],
'sp' => [
'entityId' => '{{SAML_SP_ENTITY_ID}}',
'assertionConsumerService' => [
'url' => '{{SAML_BASE_URL}}/acs',
'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST',
],
'singleLogoutService' => [
'url' => '{{SAML_BASE_URL}}/sls',
'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
],
'NameIDFormat' => 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
'x509cert' => '',
'privateKey' => '',
],
'security' => [
'nameIdEncrypted' => false,
'authnRequestsSigned' => false,
'logoutRequestSigned' => false,
'logoutResponseSigned' => false,
'signMetadata' => false,
'wantMessagesSigned' => false,
'wantAssertionsSigned' => false,
'wantAssertionsEncrypted' => false,
'wantNameIdEncrypted' => false,
],
];