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

@@ -169,7 +169,7 @@ public partial class CreateInstanceViewModel : ObservableObject
var mySqlDbName = (await settings.GetAsync(SettingsService.DefaultMySqlDbTemplate, "{abbrev}_cms_db")).Replace("{abbrev}", abbrev);
var mySqlUser = (await settings.GetAsync(SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms_user")).Replace("{abbrev}", abbrev);
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);

View File

@@ -80,7 +80,7 @@ public partial class InstanceDetailsViewModel : ObservableObject
// Derive the instance URL from the CMS server name template
var serverTemplate = await settings.GetAsync(
SettingsService.DefaultCmsServerNameTemplate, "{abbrev}.ots-signs.com");
SettingsService.DefaultCmsServerNameTemplate, "app.ots-signs.com");
var serverName = serverTemplate.Replace("{abbrev}", instance.CustomerAbbrev);
InstanceUrl = $"https://{serverName}/{instance.CustomerAbbrev.Trim().ToLowerInvariant()}";

View File

@@ -6,6 +6,7 @@ using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using OTSSignsOrchestrator.Core.Configuration;
using OTSSignsOrchestrator.Core.Models.DTOs;
using OTSSignsOrchestrator.Core.Services;
namespace OTSSignsOrchestrator.Desktop.ViewModels;
@@ -55,7 +56,7 @@ public partial class SettingsViewModel : ObservableObject
[ObservableProperty] private string _defaultNewtImage = "fosrl/newt";
[ObservableProperty] private string _defaultMemcachedImage = "memcached:alpine";
[ObservableProperty] private string _defaultQuickChartImage = "ianw/quickchart";
[ObservableProperty] private string _defaultCmsServerNameTemplate = "{abbrev}.ots-signs.com";
[ObservableProperty] private string _defaultCmsServerNameTemplate = "app.ots-signs.com";
[ObservableProperty] private string _defaultThemeHostPath = "/cms/{abbrev}-cms-theme-custom";
[ObservableProperty] private string _defaultMySqlDbTemplate = "{abbrev}_cms_db";
[ObservableProperty] private string _defaultMySqlUserTemplate = "{abbrev}_cms_user";
@@ -71,6 +72,24 @@ public partial class SettingsViewModel : ObservableObject
[ObservableProperty] private string _bitwardenProjectId = string.Empty;
[ObservableProperty] private string _bitwardenInstanceProjectId = string.Empty;
// ── Authentik (SAML IdP) ────────────────────────────────────────
[ObservableProperty] private string _authentikUrl = string.Empty;
[ObservableProperty] private string _authentikApiKey = string.Empty;
[ObservableProperty] private string _authentikAuthorizationFlowSlug = string.Empty;
[ObservableProperty] private string _authentikInvalidationFlowSlug = string.Empty;
[ObservableProperty] private string _authentikSigningKeypairId = string.Empty;
[ObservableProperty] private string _authentikStatusMessage = string.Empty;
[ObservableProperty] private bool _isAuthentikBusy;
// Dropdown collections for Authentik flows / keypairs
public ObservableCollection<AuthentikFlowItem> AuthentikAuthorizationFlows { get; } = new();
public ObservableCollection<AuthentikFlowItem> AuthentikInvalidationFlows { get; } = new();
public ObservableCollection<AuthentikKeypairItem> AuthentikKeypairs { get; } = new();
[ObservableProperty] private AuthentikFlowItem? _selectedAuthorizationFlow;
[ObservableProperty] private AuthentikFlowItem? _selectedInvalidationFlow;
[ObservableProperty] private AuthentikKeypairItem? _selectedSigningKeypair;
// ── Xibo Bootstrap OAuth2 ─────────────────────────────────────
[ObservableProperty] private string _xiboBootstrapClientId = string.Empty;
[ObservableProperty] private string _xiboBootstrapClientSecret = string.Empty;
@@ -85,22 +104,27 @@ public partial class SettingsViewModel : ObservableObject
[ObservableProperty] private bool _isBitwardenConfigured;
[RelayCommand]
private async Task LoadAsync()
private Task LoadAsync() => LoadCoreAsync(skipBitwarden: false);
private async Task LoadCoreAsync(bool skipBitwarden)
{
IsBusy = true;
try
{
// ── Load Bitwarden bootstrap config from IOptions<BitwardenOptions> ──
var bwOptions = _services.GetRequiredService<IOptions<BitwardenOptions>>().Value;
BitwardenIdentityUrl = bwOptions.IdentityUrl;
BitwardenApiUrl = bwOptions.ApiUrl;
BitwardenAccessToken = bwOptions.AccessToken;
BitwardenOrganizationId = bwOptions.OrganizationId;
BitwardenProjectId = bwOptions.ProjectId;
BitwardenInstanceProjectId = bwOptions.InstanceProjectId;
if (!skipBitwarden)
{
// ── Load Bitwarden bootstrap config (IOptionsMonitor picks up appsettings.json changes) ──
var bwOptions = _services.GetRequiredService<IOptionsMonitor<BitwardenOptions>>().CurrentValue;
BitwardenIdentityUrl = bwOptions.IdentityUrl;
BitwardenApiUrl = bwOptions.ApiUrl;
BitwardenAccessToken = bwOptions.AccessToken;
BitwardenOrganizationId = bwOptions.OrganizationId;
BitwardenProjectId = bwOptions.ProjectId;
BitwardenInstanceProjectId = bwOptions.InstanceProjectId;
}
IsBitwardenConfigured = !string.IsNullOrWhiteSpace(bwOptions.AccessToken)
&& !string.IsNullOrWhiteSpace(bwOptions.OrganizationId);
IsBitwardenConfigured = !string.IsNullOrWhiteSpace(BitwardenAccessToken)
&& !string.IsNullOrWhiteSpace(BitwardenOrganizationId);
if (!IsBitwardenConfigured)
{
@@ -146,7 +170,7 @@ public partial class SettingsViewModel : ObservableObject
DefaultNewtImage = await svc.GetAsync(SettingsService.DefaultNewtImage, "fosrl/newt");
DefaultMemcachedImage = await svc.GetAsync(SettingsService.DefaultMemcachedImage, "memcached:alpine");
DefaultQuickChartImage = await svc.GetAsync(SettingsService.DefaultQuickChartImage, "ianw/quickchart");
DefaultCmsServerNameTemplate = await svc.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "{abbrev}.ots-signs.com");
DefaultCmsServerNameTemplate = await svc.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "app.ots-signs.com");
DefaultThemeHostPath = await svc.GetAsync(SettingsService.DefaultThemeHostPath, "/cms/{abbrev}-cms-theme-custom");
DefaultMySqlDbTemplate = await svc.GetAsync(SettingsService.DefaultMySqlDbTemplate, "{abbrev}_cms_db");
DefaultMySqlUserTemplate = await svc.GetAsync(SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms_user");
@@ -154,6 +178,17 @@ public partial class SettingsViewModel : ObservableObject
DefaultPhpUploadMaxFilesize = await svc.GetAsync(SettingsService.DefaultPhpUploadMaxFilesize, "10G");
DefaultPhpMaxExecutionTime = await svc.GetAsync(SettingsService.DefaultPhpMaxExecutionTime, "600");
// Authentik
AuthentikUrl = await svc.GetAsync(SettingsService.AuthentikUrl, string.Empty);
AuthentikApiKey = await svc.GetAsync(SettingsService.AuthentikApiKey, string.Empty);
AuthentikAuthorizationFlowSlug = await svc.GetAsync(SettingsService.AuthentikAuthorizationFlowSlug, string.Empty);
AuthentikInvalidationFlowSlug = await svc.GetAsync(SettingsService.AuthentikInvalidationFlowSlug, string.Empty);
AuthentikSigningKeypairId = await svc.GetAsync(SettingsService.AuthentikSigningKeypairId, string.Empty);
// If Authentik URL + key are configured, try loading dropdowns
if (!string.IsNullOrWhiteSpace(AuthentikUrl) && !string.IsNullOrWhiteSpace(AuthentikApiKey))
await FetchAuthentikDropdownsInternalAsync();
// Xibo Bootstrap
XiboBootstrapClientId = await svc.GetAsync(SettingsService.XiboBootstrapClientId, string.Empty);
XiboBootstrapClientSecret = await svc.GetAsync(SettingsService.XiboBootstrapClientSecret, string.Empty);
@@ -171,28 +206,51 @@ public partial class SettingsViewModel : ObservableObject
}
[RelayCommand]
private async Task SaveAsync()
private async Task SaveBitwardenLocalAsync()
{
IsBusy = true;
try
{
// ── 1. Save Bitwarden bootstrap config to appsettings.json ──
await SaveBitwardenConfigToFileAsync();
// Check if Bitwarden is now configured
IsBitwardenConfigured = !string.IsNullOrWhiteSpace(BitwardenAccessToken)
&& !string.IsNullOrWhiteSpace(BitwardenOrganizationId);
StatusMessage = IsBitwardenConfigured
? "Bitwarden config saved to appsettings.json."
: "Bitwarden config saved. Fill in Access Token and Org ID to enable all settings.";
}
catch (Exception ex)
{
StatusMessage = $"Error saving Bitwarden config: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task PullFromBitwardenAsync()
{
await LoadCoreAsync(skipBitwarden: true);
}
[RelayCommand]
private async Task PushToBitwardenAsync()
{
IsBusy = true;
try
{
if (!IsBitwardenConfigured)
{
StatusMessage = "Bitwarden config saved to appsettings.json. Fill in Access Token and Org ID to enable all settings.";
StatusMessage = "Bitwarden is not configured. Save Bitwarden config first.";
return;
}
// ── 2. Save all other settings to Bitwarden ──
using var scope = _services.CreateScope();
var svc = scope.ServiceProvider.GetRequiredService<SettingsService>();
svc.InvalidateCache(); // force re-read after config change
svc.InvalidateCache();
var settings = new List<(string Key, string? Value, string Category, bool IsSensitive)>
{
@@ -238,13 +296,20 @@ public partial class SettingsViewModel : ObservableObject
(SettingsService.DefaultPhpUploadMaxFilesize, DefaultPhpUploadMaxFilesize, SettingsService.CatDefaults, false),
(SettingsService.DefaultPhpMaxExecutionTime, DefaultPhpMaxExecutionTime, SettingsService.CatDefaults, false),
// Authentik
(SettingsService.AuthentikUrl, NullIfEmpty(AuthentikUrl), SettingsService.CatAuthentik, false),
(SettingsService.AuthentikApiKey, NullIfEmpty(AuthentikApiKey), SettingsService.CatAuthentik, true),
(SettingsService.AuthentikAuthorizationFlowSlug, NullIfEmpty(SelectedAuthorizationFlow?.Slug ?? AuthentikAuthorizationFlowSlug), SettingsService.CatAuthentik, false),
(SettingsService.AuthentikInvalidationFlowSlug, NullIfEmpty(SelectedInvalidationFlow?.Slug ?? AuthentikInvalidationFlowSlug), SettingsService.CatAuthentik, false),
(SettingsService.AuthentikSigningKeypairId, NullIfEmpty(SelectedSigningKeypair?.Pk ?? AuthentikSigningKeypairId), SettingsService.CatAuthentik, false),
// Xibo Bootstrap
(SettingsService.XiboBootstrapClientId, NullIfEmpty(XiboBootstrapClientId), SettingsService.CatXibo, false),
(SettingsService.XiboBootstrapClientSecret, NullIfEmpty(XiboBootstrapClientSecret), SettingsService.CatXibo, true),
};
await svc.SaveManyAsync(settings);
StatusMessage = "Settings saved to Bitwarden.";
StatusMessage = "Settings pushed to Bitwarden.";
}
catch (Exception ex)
{
@@ -289,6 +354,135 @@ public partial class SettingsViewModel : ObservableObject
}
}
// ─────────────────────────────────────────────────────────────────────────
// Authentik: save, test, fetch dropdowns
// ─────────────────────────────────────────────────────────────────────────
[RelayCommand]
private async Task SaveAndTestAuthentikAsync()
{
if (string.IsNullOrWhiteSpace(AuthentikUrl) || string.IsNullOrWhiteSpace(AuthentikApiKey))
{
AuthentikStatusMessage = "Authentik URL and API Token are required.";
return;
}
IsAuthentikBusy = true;
AuthentikStatusMessage = "Saving Authentik settings and testing connection...";
try
{
// Persist URL + API key first so subsequent calls work
using var scope = _services.CreateScope();
var svc = scope.ServiceProvider.GetRequiredService<SettingsService>();
await svc.SetAsync(SettingsService.AuthentikUrl, AuthentikUrl.Trim(), SettingsService.CatAuthentik);
await svc.SetAsync(SettingsService.AuthentikApiKey, AuthentikApiKey.Trim(), SettingsService.CatAuthentik, isSensitive: true);
var authentik = scope.ServiceProvider.GetRequiredService<IAuthentikService>();
var (ok, msg) = await authentik.TestConnectionAsync(AuthentikUrl.Trim(), AuthentikApiKey.Trim());
if (!ok)
{
AuthentikStatusMessage = $"Connection failed: {msg}";
return;
}
AuthentikStatusMessage = "Connected — loading flows and keypairs...";
// Now fetch dropdowns
await FetchAuthentikDropdownsInternalAsync(AuthentikUrl.Trim(), AuthentikApiKey.Trim());
// Save selected flow/keypair values
var authSlug = SelectedAuthorizationFlow?.Slug ?? AuthentikAuthorizationFlowSlug;
var invalSlug = SelectedInvalidationFlow?.Slug ?? AuthentikInvalidationFlowSlug;
var kpId = SelectedSigningKeypair?.Pk ?? AuthentikSigningKeypairId;
await svc.SetAsync(SettingsService.AuthentikAuthorizationFlowSlug, authSlug, SettingsService.CatAuthentik);
await svc.SetAsync(SettingsService.AuthentikInvalidationFlowSlug, invalSlug, SettingsService.CatAuthentik);
await svc.SetAsync(SettingsService.AuthentikSigningKeypairId, kpId, SettingsService.CatAuthentik);
AuthentikStatusMessage = $"Authentik connected. {AuthentikAuthorizationFlows.Count} flows, {AuthentikKeypairs.Count} keypair(s) loaded.";
}
catch (Exception ex)
{
AuthentikStatusMessage = $"Error: {ex.Message}";
}
finally
{
IsAuthentikBusy = false;
}
}
[RelayCommand]
private async Task FetchAuthentikDropdownsAsync()
{
IsAuthentikBusy = true;
AuthentikStatusMessage = "Fetching flows and keypairs from Authentik...";
try
{
await FetchAuthentikDropdownsInternalAsync();
AuthentikStatusMessage = $"Loaded {AuthentikAuthorizationFlows.Count} flows, {AuthentikKeypairs.Count} keypair(s).";
}
catch (Exception ex)
{
AuthentikStatusMessage = $"Error fetching data: {ex.Message}";
}
finally
{
IsAuthentikBusy = false;
}
}
private async Task FetchAuthentikDropdownsInternalAsync(
string? overrideUrl = null, string? overrideApiKey = null)
{
using var scope = _services.CreateScope();
var authentik = scope.ServiceProvider.GetRequiredService<IAuthentikService>();
var flows = await authentik.ListFlowsAsync(overrideUrl, overrideApiKey);
var keypairs = await authentik.ListKeypairsAsync(overrideUrl, overrideApiKey);
// Populate authorization flows (designation = "authorization")
AuthentikAuthorizationFlows.Clear();
foreach (var f in flows.Where(f => f.Designation == "authorization"))
AuthentikAuthorizationFlows.Add(f);
// Populate invalidation flows (designation = "invalidation")
AuthentikInvalidationFlows.Clear();
foreach (var f in flows.Where(f => f.Designation == "invalidation"))
AuthentikInvalidationFlows.Add(f);
// Populate keypairs
AuthentikKeypairs.Clear();
// Add a "None" option
AuthentikKeypairs.Add(new AuthentikKeypairItem { Pk = "", Name = "(none)" });
foreach (var k in keypairs)
AuthentikKeypairs.Add(k);
// Select items matching saved slugs
SelectedAuthorizationFlow = AuthentikAuthorizationFlows
.FirstOrDefault(f => string.Equals(f.Slug, AuthentikAuthorizationFlowSlug, StringComparison.OrdinalIgnoreCase))
?? AuthentikAuthorizationFlows.FirstOrDefault(f => f.Slug == "default-provider-authorization-implicit-consent")
?? AuthentikAuthorizationFlows.FirstOrDefault();
SelectedInvalidationFlow = AuthentikInvalidationFlows
.FirstOrDefault(f => string.Equals(f.Slug, AuthentikInvalidationFlowSlug, StringComparison.OrdinalIgnoreCase))
?? AuthentikInvalidationFlows.FirstOrDefault(f => f.Slug == "default-provider-invalidation-flow")
?? AuthentikInvalidationFlows.FirstOrDefault();
SelectedSigningKeypair = string.IsNullOrWhiteSpace(AuthentikSigningKeypairId)
? AuthentikKeypairs.First() // "(none)"
: AuthentikKeypairs.FirstOrDefault(k => k.Pk == AuthentikSigningKeypairId)
?? AuthentikKeypairs.First();
// Update slug fields to match selection
if (SelectedAuthorizationFlow != null)
AuthentikAuthorizationFlowSlug = SelectedAuthorizationFlow.Slug;
if (SelectedInvalidationFlow != null)
AuthentikInvalidationFlowSlug = SelectedInvalidationFlow.Slug;
if (SelectedSigningKeypair != null)
AuthentikSigningKeypairId = SelectedSigningKeypair.Pk;
}
[RelayCommand]
private async Task TestXiboBootstrapAsync()
{
@@ -305,7 +499,7 @@ public partial class SettingsViewModel : ObservableObject
using var scope = _services.CreateScope();
var xibo = scope.ServiceProvider.GetRequiredService<XiboApiService>();
var svc = scope.ServiceProvider.GetRequiredService<SettingsService>();
var cmsServerTemplate = await svc.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "{abbrev}.ots-signs.com");
var cmsServerTemplate = await svc.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "app.ots-signs.com");
// Use a placeholder URL — user must configure a live instance for full test
StatusMessage = "Xibo bootstrap credentials saved. Connect test requires a live instance URL — use 'Details' on an instance to verify.";
}