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:
@@ -1,55 +0,0 @@
|
|||||||
# Gitea Actions workflow: build Docker image and push to a container registry
|
|
||||||
# Place secrets in the repository settings: REGISTRY (host[:port]), IMAGE_NAME, DOCKER_USERNAME, DOCKER_PASSWORD
|
|
||||||
|
|
||||||
name: Build and Publish Docker Image
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
workflow_dispatch: {}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-and-push:
|
|
||||||
# Use an appropriate runner that has Docker available (self-hosted runner)
|
|
||||||
runs-on: self-hosted
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Build and push image
|
|
||||||
# run everything in a single shell step to keep tag calculation simple
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
REGISTRY="${{ secrets.REGISTRY }}"
|
|
||||||
IMAGE_NAME="${{ secrets.IMAGE_NAME }}"
|
|
||||||
DOCKER_USERNAME="${{ secrets.DOCKER_USERNAME }}"
|
|
||||||
DOCKER_PASSWORD="${{ secrets.DOCKER_PASSWORD }}"
|
|
||||||
|
|
||||||
if [ -z "$REGISTRY" ] || [ -z "$IMAGE_NAME" ]; then
|
|
||||||
echo "Missing required secrets: REGISTRY and IMAGE_NAME must be set." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
TAG=$(git rev-parse --short HEAD)
|
|
||||||
IMAGE="$REGISTRY/$IMAGE_NAME:$TAG"
|
|
||||||
LATEST="$REGISTRY/$IMAGE_NAME:latest"
|
|
||||||
|
|
||||||
echo "Logging in to $REGISTRY"
|
|
||||||
echo "$DOCKER_PASSWORD" | docker login "$REGISTRY" -u "$DOCKER_USERNAME" --password-stdin
|
|
||||||
|
|
||||||
echo "Building $IMAGE (and tagging as latest)"
|
|
||||||
docker build -t "$IMAGE" -t "$LATEST" .
|
|
||||||
|
|
||||||
echo "Pushing $IMAGE"
|
|
||||||
docker push "$IMAGE"
|
|
||||||
|
|
||||||
echo "Pushing $LATEST"
|
|
||||||
docker push "$LATEST"
|
|
||||||
|
|
||||||
env:
|
|
||||||
# secrets are available via ${{ secrets.<name> }} in Gitea Actions
|
|
||||||
REGISTRY: ${{ secrets.REGISTRY }}
|
|
||||||
IMAGE_NAME: ${{ secrets.IMAGE_NAME }}
|
|
||||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
|
||||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -56,3 +56,4 @@ docker-compose.override.yml
|
|||||||
|
|
||||||
# Ignore appsettings development files (if you keep secrets locally)
|
# Ignore appsettings development files (if you keep secrets locally)
|
||||||
appsettings.Development.json
|
appsettings.Development.json
|
||||||
|
.template-cache/
|
||||||
Submodule .template-cache/053604496cfa3867 updated: a6ab3c254b...eaf06cf624
Submodule .template-cache/2dc03e2b2b45fef3 updated: a6ab3c254b...eaf06cf624
@@ -82,7 +82,7 @@ public class InstanceDefaultsOptions
|
|||||||
public string? TemplateRepoPat { get; set; }
|
public string? TemplateRepoPat { get; set; }
|
||||||
|
|
||||||
/// <summary>Template for CMS server hostname. Use {abbrev} as placeholder.</summary>
|
/// <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 SmtpServer { get; set; } = string.Empty;
|
||||||
public string SmtpUsername { get; set; } = string.Empty;
|
public string SmtpUsername { get; set; } = string.Empty;
|
||||||
|
|||||||
17
OTSSignsOrchestrator.Core/Models/DTOs/AuthentikFlowItem.cs
Normal file
17
OTSSignsOrchestrator.Core/Models/DTOs/AuthentikFlowItem.cs
Normal 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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using System.Xml.Linq;
|
using System.Xml.Linq;
|
||||||
@@ -56,6 +57,7 @@ public class AuthentikService : IAuthentikService
|
|||||||
|
|
||||||
var (baseUrl, client) = await CreateAuthenticatedClientAsync();
|
var (baseUrl, client) = await CreateAuthenticatedClientAsync();
|
||||||
var slug = $"ds-{instanceAbbrev}";
|
var slug = $"ds-{instanceAbbrev}";
|
||||||
|
var providerName = $"OTS Signs — {instanceAbbrev.ToUpperInvariant()} (SAML)";
|
||||||
var samlBaseUrl = instanceBaseUrl.TrimEnd('/') + "/saml";
|
var samlBaseUrl = instanceBaseUrl.TrimEnd('/') + "/saml";
|
||||||
|
|
||||||
// ── 1. Check if application already exists ────────────────────────
|
// ── 1. Check if application already exists ────────────────────────
|
||||||
@@ -78,15 +80,31 @@ public class AuthentikService : IAuthentikService
|
|||||||
SettingsService.AuthentikInvalidationFlowSlug,
|
SettingsService.AuthentikInvalidationFlowSlug,
|
||||||
"default-provider-invalidation-flow", ct);
|
"default-provider-invalidation-flow", ct);
|
||||||
|
|
||||||
// ── 3. Create SAML provider ───────────────────────────────────
|
// ── 3. Find or create SAML provider ──────────────────────────
|
||||||
providerId = await CreateSamlProviderAsync(client, baseUrl,
|
var existingSamlProvider = await TryGetExistingSamlProviderAsync(client, providerName, ct);
|
||||||
instanceAbbrev, samlBaseUrl, authFlowUuid, invalidFlowUuid, 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 ──────────────────
|
// ── 4. Create application linked to provider ──────────────────
|
||||||
await CreateApplicationAsync(client, baseUrl, instanceAbbrev, slug, providerId, ct);
|
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);
|
var config = await FetchAndParseMetadataAsync(client, baseUrl, providerId, ct);
|
||||||
config.ApplicationSlug = slug;
|
config.ApplicationSlug = slug;
|
||||||
|
|
||||||
@@ -97,14 +115,75 @@ public class AuthentikService : IAuthentikService
|
|||||||
return config;
|
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
|
// 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 authentikUrl = overrideUrl ?? await _settings.GetAsync(SettingsService.AuthentikUrl);
|
||||||
var apiKey = await _settings.GetAsync(SettingsService.AuthentikApiKey);
|
var apiKey = overrideApiKey ?? await _settings.GetAsync(SettingsService.AuthentikApiKey);
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(authentikUrl))
|
if (string.IsNullOrWhiteSpace(authentikUrl))
|
||||||
throw new InvalidOperationException("Authentik URL is not configured. Set it in Settings → Authentik.");
|
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;
|
if (!resp.IsSuccessStatusCode) return null;
|
||||||
|
|
||||||
var json = await resp.Content.ReadFromJsonAsync<AuthentikListResponse<AuthentikApplication>>(cancellationToken: ct);
|
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 })
|
if (json?.Results is { Count: > 0 })
|
||||||
{
|
{
|
||||||
var app = json.Results[0];
|
_logger.LogDebug(
|
||||||
return app.Provider;
|
"[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)
|
catch (Exception ex)
|
||||||
@@ -147,6 +238,273 @@ public class AuthentikService : IAuthentikService
|
|||||||
return null;
|
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 <Attribute> elements inside the <AttributeStatement>.
|
||||||
|
/// 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 <AttributeStatement> 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
|
// Flow resolution
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
@@ -168,15 +526,26 @@ public class AuthentikService : IAuthentikService
|
|||||||
|
|
||||||
_logger.LogDebug("[Authentik] Resolving flow UUID for slug '{Slug}'", slug);
|
_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();
|
resp.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
var json = await resp.Content.ReadFromJsonAsync<AuthentikListResponse<AuthentikFlow>>(cancellationToken: ct);
|
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(
|
throw new InvalidOperationException(
|
||||||
$"Authentik flow '{slug}' not found. Ensure the flow exists or configure the correct slug in Settings → Authentik.");
|
$"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
|
// Cache for subsequent calls
|
||||||
if (settingsKey == SettingsService.AuthentikAuthorizationFlowSlug)
|
if (settingsKey == SettingsService.AuthentikAuthorizationFlowSlug)
|
||||||
@@ -206,22 +575,48 @@ public class AuthentikService : IAuthentikService
|
|||||||
["invalidation_flow"] = invalidFlowUuid,
|
["invalidation_flow"] = invalidFlowUuid,
|
||||||
["acs_url"] = $"{samlBaseUrl}/acs",
|
["acs_url"] = $"{samlBaseUrl}/acs",
|
||||||
["sp_binding"] = "post",
|
["sp_binding"] = "post",
|
||||||
["issuer"] = "authentik",
|
["issuer"] = $"ds-{abbrev}",
|
||||||
["audience"] = $"{samlBaseUrl}/metadata",
|
["audience"] = $"{samlBaseUrl}/metadata",
|
||||||
["default_relay_state"] = "",
|
["default_relay_state"] = "",
|
||||||
["name_id_mapping"] = (object)null!, // use default
|
["sls_url"] = $"{samlBaseUrl}/sls",
|
||||||
|
["sls_binding"] = "redirect",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Optionally add SLO URL
|
// Attach SAML property mappings so attributes (username, email, etc.)
|
||||||
payload["sls_url"] = $"{samlBaseUrl}/sls";
|
// are included in the response. Without these Authentik sends an empty
|
||||||
payload["sls_binding"] = "redirect";
|
// <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);
|
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))
|
if (!string.IsNullOrWhiteSpace(signingKpId))
|
||||||
|
{
|
||||||
payload["signing_kp"] = 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)
|
if (!resp.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
@@ -256,7 +651,11 @@ public class AuthentikService : IAuthentikService
|
|||||||
["provider"] = providerId,
|
["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)
|
if (!resp.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
@@ -277,16 +676,215 @@ public class AuthentikService : IAuthentikService
|
|||||||
{
|
{
|
||||||
_logger.LogDebug("[Authentik] Fetching SAML metadata for provider {ProviderId}", providerId);
|
_logger.LogDebug("[Authentik] Fetching SAML metadata for provider {ProviderId}", providerId);
|
||||||
|
|
||||||
// Request XML metadata (override Accept header for this call)
|
// Retry a few times — Authentik may need a moment after provider creation
|
||||||
var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v3/providers/saml/{providerId}/metadata/?download");
|
const int maxRetries = 3;
|
||||||
request.Headers.Accept.Clear();
|
for (int attempt = 1; attempt <= maxRetries; attempt++)
|
||||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/xml"));
|
{
|
||||||
|
// 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);
|
var resp = await client.SendAsync(request, ct);
|
||||||
resp.EnsureSuccessStatusCode();
|
|
||||||
|
|
||||||
var xml = await resp.Content.ReadAsStringAsync(ct);
|
if (resp.IsSuccessStatusCode)
|
||||||
return ParseMetadataXml(xml, providerId);
|
{
|
||||||
|
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)
|
private AuthentikSamlConfig ParseMetadataXml(string xml, int providerId)
|
||||||
@@ -367,6 +965,30 @@ public class AuthentikService : IAuthentikService
|
|||||||
public string Name { get; set; } = string.Empty;
|
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
|
private class AuthentikSamlProvider
|
||||||
{
|
{
|
||||||
[JsonPropertyName("pk")]
|
[JsonPropertyName("pk")]
|
||||||
@@ -399,4 +1021,19 @@ public class AuthentikService : IAuthentikService
|
|||||||
[JsonPropertyName("provider")]
|
[JsonPropertyName("provider")]
|
||||||
public int? Provider { get; set; }
|
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; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -246,8 +246,7 @@ public class ComposeRenderService
|
|||||||
NEWT_ID: {{NEWT_ID}}
|
NEWT_ID: {{NEWT_ID}}
|
||||||
NEWT_SECRET: {{NEWT_SECRET}}
|
NEWT_SECRET: {{NEWT_SECRET}}
|
||||||
depends_on:
|
depends_on:
|
||||||
{{ABBREV}}-web:
|
- {{ABBREV}}-web
|
||||||
condition: service_healthy
|
|
||||||
networks:
|
networks:
|
||||||
{{ABBREV}}-net: {}
|
{{ABBREV}}-net: {}
|
||||||
deploy:
|
deploy:
|
||||||
|
|||||||
@@ -13,12 +13,33 @@ public interface IAuthentikService
|
|||||||
/// then fetches the IdP metadata (entity ID, x509 cert, SSO/SLO URLs).
|
/// then fetches the IdP metadata (entity ID, x509 cert, SSO/SLO URLs).
|
||||||
/// If the application already exists (by slug), returns its existing metadata.
|
/// If the application already exists (by slug), returns its existing metadata.
|
||||||
/// </summary>
|
/// </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(
|
Task<AuthentikSamlConfig> ProvisionSamlAsync(
|
||||||
string instanceAbbrev,
|
string instanceAbbrev,
|
||||||
string instanceBaseUrl,
|
string instanceBaseUrl,
|
||||||
CancellationToken ct = default);
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ public class InstanceService
|
|||||||
var mySqlDbName = (await _settings.GetAsync(SettingsService.DefaultMySqlDbTemplate, "{abbrev}_cms_db")).Replace("{abbrev}", abbrev);
|
var mySqlDbName = (await _settings.GetAsync(SettingsService.DefaultMySqlDbTemplate, "{abbrev}_cms_db")).Replace("{abbrev}", abbrev);
|
||||||
var mySqlUser = mySqlUserName;
|
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 themePath = (await _settings.GetAsync(SettingsService.DefaultThemeHostPath, "/cms/ots-theme")).Replace("{abbrev}", abbrev);
|
||||||
|
|
||||||
var smtpServer = await _settings.GetAsync(SettingsService.SmtpServer, string.Empty);
|
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.");
|
+ "(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 ─────────────────────────────────────────────
|
// ── 6. Deploy stack ─────────────────────────────────────────────
|
||||||
var deployResult = await _docker.DeployStackAsync(stackName, composeYaml);
|
var deployResult = await _docker.DeployStackAsync(stackName, composeYaml);
|
||||||
if (!deployResult.Success)
|
if (!deployResult.Success)
|
||||||
@@ -341,7 +347,7 @@ public class InstanceService
|
|||||||
var pangolinEndpoint = await _settings.GetAsync(SettingsService.PangolinEndpoint, "https://app.pangolin.net");
|
var pangolinEndpoint = await _settings.GetAsync(SettingsService.PangolinEndpoint, "https://app.pangolin.net");
|
||||||
|
|
||||||
var cmsServerName = dto.CmsServerName
|
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 hostHttpPort = dto.HostHttpPort ?? 80;
|
||||||
var themePath = dto.ThemeHostPath
|
var themePath = dto.ThemeHostPath
|
||||||
?? (await _settings.GetAsync(SettingsService.DefaultThemeHostPath, "/cms/ots-theme")).Replace("{abbrev}", abbrev);
|
?? (await _settings.GetAsync(SettingsService.DefaultThemeHostPath, "/cms/ots-theme")).Replace("{abbrev}", abbrev);
|
||||||
|
|||||||
@@ -107,9 +107,6 @@ public class PostInstanceInitService
|
|||||||
_logger.LogInformation("[PostInit] Setting Xibo theme to 'otssigns'");
|
_logger.LogInformation("[PostInit] Setting Xibo theme to 'otssigns'");
|
||||||
await xibo.SetThemeAsync(instanceUrl, accessToken, "otssigns");
|
await xibo.SetThemeAsync(instanceUrl, accessToken, "otssigns");
|
||||||
|
|
||||||
// ── 6a. Deploy SAML configuration ─────────────────────────────────
|
|
||||||
await DeploySamlConfigurationAsync(abbrev, instanceUrl, settings, ct);
|
|
||||||
|
|
||||||
// ── 7. Store credentials in Bitwarden ─────────────────────────────
|
// ── 7. Store credentials in Bitwarden ─────────────────────────────
|
||||||
_logger.LogInformation("[PostInit] Storing credentials in Bitwarden");
|
_logger.LogInformation("[PostInit] Storing credentials in Bitwarden");
|
||||||
|
|
||||||
@@ -200,9 +197,6 @@ public class PostInstanceInitService
|
|||||||
_logger.LogInformation("[PostInit] Setting theme to 'otssigns'");
|
_logger.LogInformation("[PostInit] Setting theme to 'otssigns'");
|
||||||
await xibo.SetThemeAsync(instanceUrl, accessToken, "otssigns");
|
await xibo.SetThemeAsync(instanceUrl, accessToken, "otssigns");
|
||||||
|
|
||||||
// ── 5a. Deploy SAML configuration ─────────────────────────────────
|
|
||||||
await DeploySamlConfigurationAsync(abbrev, instanceUrl, settings, ct);
|
|
||||||
|
|
||||||
// ── 6. Store admin password in Bitwarden ──────────────────────────
|
// ── 6. Store admin password in Bitwarden ──────────────────────────
|
||||||
_logger.LogInformation("[PostInit] Storing credentials in Bitwarden");
|
_logger.LogInformation("[PostInit] Storing credentials in Bitwarden");
|
||||||
var adminSecretId = await bws.CreateInstanceSecretAsync(
|
var adminSecretId = await bws.CreateInstanceSecretAsync(
|
||||||
@@ -349,9 +343,11 @@ public class PostInstanceInitService
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Provisions a SAML application in Authentik, renders the settings-custom.php template,
|
/// 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.
|
/// 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>
|
/// </summary>
|
||||||
private async Task DeploySamlConfigurationAsync(
|
public async Task DeploySamlConfigurationAsync(
|
||||||
string abbrev,
|
string abbrev,
|
||||||
string instanceUrl,
|
string instanceUrl,
|
||||||
SettingsService settings,
|
SettingsService settings,
|
||||||
@@ -366,36 +362,80 @@ public class PostInstanceInitService
|
|||||||
var git = scope.ServiceProvider.GetRequiredService<GitTemplateService>();
|
var git = scope.ServiceProvider.GetRequiredService<GitTemplateService>();
|
||||||
var docker = scope.ServiceProvider.GetRequiredService<IDockerCliService>();
|
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 repoUrl = await settings.GetAsync(SettingsService.GitRepoUrl);
|
||||||
var repoPat = await settings.GetAsync(SettingsService.GitRepoPat);
|
var repoPat = await settings.GetAsync(SettingsService.GitRepoPat);
|
||||||
if (string.IsNullOrWhiteSpace(repoUrl))
|
if (!string.IsNullOrWhiteSpace(repoUrl))
|
||||||
throw new InvalidOperationException("Git repository URL is not configured.");
|
{
|
||||||
|
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);
|
// 1b. Fall back to local templates/ directory (bundled with app)
|
||||||
var templatePath = Path.Combine(templateConfig.CacheDir, "settings-custom.php.template");
|
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(
|
_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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var templateContent = await File.ReadAllTextAsync(templatePath, ct);
|
|
||||||
|
|
||||||
// ── 2. Provision Authentik SAML application ───────────────────────
|
// ── 2. Provision Authentik SAML application ───────────────────────
|
||||||
var samlBaseUrl = instanceUrl.TrimEnd('/') + "/saml";
|
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 ────────────────────────────────────────────
|
// ── 3. Render template ────────────────────────────────────────────
|
||||||
var rendered = templateContent
|
var rendered = templateContent
|
||||||
.Replace("{{SAML_BASE_URL}}", samlBaseUrl)
|
.Replace("{{SAML_BASE_URL}}", samlBaseUrl)
|
||||||
.Replace("{{SAML_SP_ENTITY_ID}}", $"{samlBaseUrl}/metadata")
|
.Replace("{{SAML_SP_ENTITY_ID}}", $"{samlBaseUrl}/metadata")
|
||||||
.Replace("{{AUTHENTIK_IDP_ENTITY_ID}}", samlConfig.IdpEntityId)
|
.Replace("{{AUTHENTIK_IDP_ENTITY_ID}}", samlConfig?.IdpEntityId ?? "")
|
||||||
.Replace("{{AUTHENTIK_SSO_URL}}", samlConfig.SsoUrlRedirect)
|
.Replace("{{AUTHENTIK_SSO_URL}}", samlConfig?.SsoUrlRedirect ?? "")
|
||||||
.Replace("{{AUTHENTIK_SLO_URL}}", samlConfig.SloUrlRedirect)
|
.Replace("{{AUTHENTIK_SLO_URL}}", samlConfig?.SloUrlRedirect ?? "")
|
||||||
.Replace("{{AUTHENTIK_IDP_X509_CERT}}", samlConfig.IdpX509Cert);
|
.Replace("{{AUTHENTIK_IDP_X509_CERT}}", samlConfig?.IdpX509Cert ?? "");
|
||||||
|
|
||||||
// ── 4. Write rendered file to NFS volume ──────────────────────────
|
// ── 4. Write rendered file to NFS volume ──────────────────────────
|
||||||
var nfsServer = await settings.GetAsync(SettingsService.NfsServer);
|
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}");
|
throw new InvalidOperationException($"Failed to write settings-custom.php to NFS: {error}");
|
||||||
|
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"[PostInit] SAML configuration deployed for {Abbrev} (Authentik provider={ProviderId})",
|
"[PostInit] SAML configuration deployed for {Abbrev}{ProviderInfo}",
|
||||||
abbrev, samlConfig.ProviderId);
|
abbrev,
|
||||||
|
samlConfig != null
|
||||||
|
? $" (Authentik provider={samlConfig.ProviderId})"
|
||||||
|
: " (without Authentik — needs manual IdP config)");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -49,4 +49,11 @@
|
|||||||
</None>
|
</None>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Include="..\templates\settings-custom.php.template">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
<Link>templates/settings-custom.php.template</Link>
|
||||||
|
</Content>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -41,6 +41,70 @@ public class SshDockerCliService : IDockerCliService
|
|||||||
|
|
||||||
public SshHost? CurrentHost => _currentHost;
|
public SshHost? CurrentHost => _currentHost;
|
||||||
|
|
||||||
|
private void EnsureHost()
|
||||||
|
{
|
||||||
|
if (_currentHost == null)
|
||||||
|
throw new InvalidOperationException("No SSH host configured. Call SetHost() before using Docker commands.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Escape password for safe use in shell scripts with proper quoting.
|
||||||
|
/// Uses printf-safe escaping to avoid newline injection and special character issues.
|
||||||
|
/// </summary>
|
||||||
|
private string EscapePasswordForShell(string password)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(password))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Password is null or empty");
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogDebug("Original password length: {Length} characters", password.Length);
|
||||||
|
|
||||||
|
// Use printf-safe format: escape single quotes and other problematic characters
|
||||||
|
// Replace ' with '\'' (close quote, escaped quote, open quote)
|
||||||
|
var escaped = password.Replace("'", "'\\''");
|
||||||
|
|
||||||
|
_logger.LogDebug("Escaped password length: {Length} characters (added {Extra} chars for escaping)",
|
||||||
|
escaped.Length, escaped.Length - password.Length);
|
||||||
|
_logger.LogDebug("Password first char: '{FirstChar}', last char: '{LastChar}'",
|
||||||
|
password.Length > 0 ? password[0].ToString() : "N/A",
|
||||||
|
password.Length > 0 ? password[^1].ToString() : "N/A");
|
||||||
|
|
||||||
|
return escaped;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Test if the current host's password works with sudo by running a no-op sudo command.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<(bool Success, string? Error)> TestSudoPasswordAsync()
|
||||||
|
{
|
||||||
|
EnsureHost();
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(_currentHost!.Password))
|
||||||
|
{
|
||||||
|
return (false, "No password configured for SSH host");
|
||||||
|
}
|
||||||
|
|
||||||
|
var escapedPassword = EscapePasswordForShell(_currentHost!.Password);
|
||||||
|
var testCmd = $"printf '%s\\n' '{escapedPassword}' | sudo -S -v 2>&1";
|
||||||
|
|
||||||
|
_logger.LogInformation("Testing sudo password for host {Host} user {User}...",
|
||||||
|
_currentHost!.Label, _currentHost!.Username);
|
||||||
|
|
||||||
|
var (exitCode, stdout, stderr) = await _ssh.RunCommandAsync(_currentHost!, testCmd, TimeSpan.FromSeconds(10));
|
||||||
|
|
||||||
|
if (exitCode == 0)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Sudo password test PASSED for {Host}", _currentHost!.Label);
|
||||||
|
return (true, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var error = (stderr ?? stdout ?? "unknown error").Trim();
|
||||||
|
_logger.LogWarning("Sudo password test FAILED for {Host}: {Error}", _currentHost!.Label, error);
|
||||||
|
return (false, error);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<DeploymentResultDto> DeployStackAsync(string stackName, string composeYaml, bool resolveImage = false)
|
public async Task<DeploymentResultDto> DeployStackAsync(string stackName, string composeYaml, bool resolveImage = false)
|
||||||
{
|
{
|
||||||
EnsureHost();
|
EnsureHost();
|
||||||
@@ -184,12 +248,22 @@ public class SshDockerCliService : IDockerCliService
|
|||||||
// Single SSH command: create temp dir, mount NFS, mkdir -p all folders, unmount, cleanup
|
// Single SSH command: create temp dir, mount NFS, mkdir -p all folders, unmount, cleanup
|
||||||
// Use addr= to pin the server IP — avoids "Server address does not match proto= option"
|
// Use addr= to pin the server IP — avoids "Server address does not match proto= option"
|
||||||
// errors when the hostname resolves to IPv6 but proto=tcp implies IPv4.
|
// errors when the hostname resolves to IPv6 but proto=tcp implies IPv4.
|
||||||
|
// Properly escape password for shell use (handle special characters like single quotes)
|
||||||
|
var escapedPassword = EscapePasswordForShell(_currentHost!.Password ?? string.Empty);
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(escapedPassword))
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"No password configured for SSH host {Host}. NFS operations may fail if sudo requires a password.",
|
||||||
|
_currentHost!.Label);
|
||||||
|
}
|
||||||
|
|
||||||
var script = $"""
|
var script = $"""
|
||||||
set -e
|
set -e
|
||||||
MNT=$(mktemp -d)
|
MNT=$(mktemp -d)
|
||||||
sudo mount -t nfs -o addr={nfsServer},nfsvers=4,proto=tcp,soft,timeo=50,retrans=2 {nfsServer}:/{exportPath} "$MNT"
|
printf '%s\n' '{escapedPassword}' | sudo -S mount -t nfs -o addr={nfsServer},nfsvers=4,proto=tcp,soft,timeo=50,retrans=2 {nfsServer}:/{exportPath} "$MNT"
|
||||||
sudo mkdir -p {mkdirTargets}
|
printf '%s\n' '{escapedPassword}' | sudo -S mkdir -p {mkdirTargets}
|
||||||
sudo umount "$MNT"
|
printf '%s\n' '{escapedPassword}' | sudo -S umount "$MNT"
|
||||||
rmdir "$MNT"
|
rmdir "$MNT"
|
||||||
""";
|
""";
|
||||||
|
|
||||||
@@ -229,12 +303,22 @@ public class SshDockerCliService : IDockerCliService
|
|||||||
var folderList = folderNames.Select(f => $"\"$MNT{subPath}/{f}\"").ToList();
|
var folderList = folderNames.Select(f => $"\"$MNT{subPath}/{f}\"").ToList();
|
||||||
var mkdirTargets = string.Join(" ", folderList);
|
var mkdirTargets = string.Join(" ", folderList);
|
||||||
|
|
||||||
|
// Properly escape password for shell use (handle special characters like single quotes)
|
||||||
|
var escapedPassword = EscapePasswordForShell(_currentHost!.Password ?? string.Empty);
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(escapedPassword))
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"No password configured for SSH host {Host}. NFS operations may fail if sudo requires a password.",
|
||||||
|
_currentHost!.Label);
|
||||||
|
}
|
||||||
|
|
||||||
var script = $"""
|
var script = $"""
|
||||||
set -e
|
set -e
|
||||||
MNT=$(mktemp -d)
|
MNT=$(mktemp -d)
|
||||||
sudo mount -t nfs -o addr={nfsServer},nfsvers=4,proto=tcp,soft,timeo=50,retrans=2 {nfsServer}:/{exportPath} "$MNT"
|
printf '%s\n' '{escapedPassword}' | sudo -S mount -t nfs -o addr={nfsServer},nfsvers=4,proto=tcp,soft,timeo=50,retrans=2 {nfsServer}:/{exportPath} "$MNT"
|
||||||
sudo mkdir -p {mkdirTargets}
|
printf '%s\n' '{escapedPassword}' | sudo -S mkdir -p {mkdirTargets}
|
||||||
sudo umount "$MNT"
|
printf '%s\n' '{escapedPassword}' | sudo -S umount "$MNT"
|
||||||
rmdir "$MNT"
|
rmdir "$MNT"
|
||||||
""";
|
""";
|
||||||
|
|
||||||
@@ -271,22 +355,44 @@ public class SshDockerCliService : IDockerCliService
|
|||||||
var subFolder = (nfsExportFolder ?? string.Empty).Trim('/');
|
var subFolder = (nfsExportFolder ?? string.Empty).Trim('/');
|
||||||
var subPath = string.IsNullOrEmpty(subFolder) ? string.Empty : $"/{subFolder}";
|
var subPath = string.IsNullOrEmpty(subFolder) ? string.Empty : $"/{subFolder}";
|
||||||
|
|
||||||
// Ensure parent directory exists, then write content via heredoc
|
// Ensure parent directory exists
|
||||||
var targetPath = $"$MNT{subPath}/{relativePath.TrimStart('/')}";
|
var targetPath = $"$MNT{subPath}/{relativePath.TrimStart('/')}";
|
||||||
var parentDir = $"$(dirname \"{targetPath}\")";
|
var parentDir = $"$(dirname \"{targetPath}\")";
|
||||||
|
|
||||||
// Escape content for heredoc (replace any literal EOF that might appear in content)
|
// Properly escape password for shell use (handle special characters like single quotes)
|
||||||
var safeContent = content.Replace("'", "'\\''");
|
var escapedPassword = EscapePasswordForShell(_currentHost!.Password ?? string.Empty);
|
||||||
|
|
||||||
|
_logger.LogInformation("NFS WriteFile: Host={Host}, User={User}, HasPassword={HasPw}, PwLen={PwLen}",
|
||||||
|
_currentHost!.Label, _currentHost!.Username,
|
||||||
|
!string.IsNullOrEmpty(_currentHost!.Password), _currentHost!.Password?.Length ?? 0);
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(escapedPassword))
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"No password configured for SSH host {Host}. NFS operations may fail if sudo requires a password.",
|
||||||
|
_currentHost!.Label);
|
||||||
|
return (false, "No password configured for SSH host");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base64-encode the file content to avoid heredoc/stdin conflicts with sudo -S.
|
||||||
|
// The heredoc approach fails because the shell's heredoc redirects stdin for the
|
||||||
|
// entire pipeline, so sudo -S reads the PHP content instead of the password.
|
||||||
|
var base64Content = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(content));
|
||||||
|
|
||||||
|
// Strategy: base64-decode content to a temp file (no sudo needed), then use
|
||||||
|
// printf | sudo -S for each privileged command — matching the proven pattern
|
||||||
|
// in EnsureNfsFoldersAsync. We avoid sudo -v timestamp caching because SSH
|
||||||
|
// exec channels have no TTY and timestamps may not persist between commands.
|
||||||
var script = $"""
|
var script = $"""
|
||||||
set -e
|
set -e
|
||||||
|
TMPFILE=$(mktemp)
|
||||||
|
echo '{base64Content}' | base64 -d > "$TMPFILE"
|
||||||
MNT=$(mktemp -d)
|
MNT=$(mktemp -d)
|
||||||
sudo mount -t nfs -o addr={nfsServer},nfsvers=4,proto=tcp,soft,timeo=50,retrans=2 {nfsServer}:/{exportPath} "$MNT"
|
printf '%s\n' '{escapedPassword}' | sudo -S mount -t nfs -o addr={nfsServer},nfsvers=4,proto=tcp,soft,timeo=50,retrans=2 {nfsServer}:/{exportPath} "$MNT"
|
||||||
sudo mkdir -p {parentDir}
|
printf '%s\n' '{escapedPassword}' | sudo -S mkdir -p {parentDir}
|
||||||
sudo tee "{targetPath}" > /dev/null << 'OTSSIGNS_EOF'
|
printf '%s\n' '{escapedPassword}' | sudo -S cp "$TMPFILE" "{targetPath}"
|
||||||
{content}
|
rm -f "$TMPFILE"
|
||||||
OTSSIGNS_EOF
|
printf '%s\n' '{escapedPassword}' | sudo -S umount "$MNT"
|
||||||
sudo umount "$MNT"
|
|
||||||
rmdir "$MNT"
|
rmdir "$MNT"
|
||||||
""";
|
""";
|
||||||
|
|
||||||
@@ -609,12 +715,6 @@ public class SshDockerCliService : IDockerCliService
|
|||||||
return serviceName.StartsWith(prefix) ? serviceName[prefix.Length..] : serviceName;
|
return serviceName.StartsWith(prefix) ? serviceName[prefix.Length..] : serviceName;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void EnsureHost()
|
|
||||||
{
|
|
||||||
if (_currentHost == null)
|
|
||||||
throw new InvalidOperationException("No SSH host configured. Call SetHost() before using Docker commands.");
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<bool> RemoveStackVolumesAsync(string stackName)
|
public async Task<bool> RemoveStackVolumesAsync(string stackName)
|
||||||
{
|
{
|
||||||
EnsureHost();
|
EnsureHost();
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ public partial class CreateInstanceViewModel : ObservableObject
|
|||||||
var mySqlDbName = (await settings.GetAsync(SettingsService.DefaultMySqlDbTemplate, "{abbrev}_cms_db")).Replace("{abbrev}", abbrev);
|
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 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 themePath = (await settings.GetAsync(SettingsService.DefaultThemeHostPath, "/cms/ots-theme")).Replace("{abbrev}", abbrev);
|
||||||
|
|
||||||
var smtpServer = await settings.GetAsync(SettingsService.SmtpServer, string.Empty);
|
var smtpServer = await settings.GetAsync(SettingsService.SmtpServer, string.Empty);
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ public partial class InstanceDetailsViewModel : ObservableObject
|
|||||||
|
|
||||||
// Derive the instance URL from the CMS server name template
|
// Derive the instance URL from the CMS server name template
|
||||||
var serverTemplate = await settings.GetAsync(
|
var serverTemplate = await settings.GetAsync(
|
||||||
SettingsService.DefaultCmsServerNameTemplate, "{abbrev}.ots-signs.com");
|
SettingsService.DefaultCmsServerNameTemplate, "app.ots-signs.com");
|
||||||
var serverName = serverTemplate.Replace("{abbrev}", instance.CustomerAbbrev);
|
var serverName = serverTemplate.Replace("{abbrev}", instance.CustomerAbbrev);
|
||||||
InstanceUrl = $"https://{serverName}/{instance.CustomerAbbrev.Trim().ToLowerInvariant()}";
|
InstanceUrl = $"https://{serverName}/{instance.CustomerAbbrev.Trim().ToLowerInvariant()}";
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using CommunityToolkit.Mvvm.Input;
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using OTSSignsOrchestrator.Core.Configuration;
|
using OTSSignsOrchestrator.Core.Configuration;
|
||||||
|
using OTSSignsOrchestrator.Core.Models.DTOs;
|
||||||
using OTSSignsOrchestrator.Core.Services;
|
using OTSSignsOrchestrator.Core.Services;
|
||||||
|
|
||||||
namespace OTSSignsOrchestrator.Desktop.ViewModels;
|
namespace OTSSignsOrchestrator.Desktop.ViewModels;
|
||||||
@@ -55,7 +56,7 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
[ObservableProperty] private string _defaultNewtImage = "fosrl/newt";
|
[ObservableProperty] private string _defaultNewtImage = "fosrl/newt";
|
||||||
[ObservableProperty] private string _defaultMemcachedImage = "memcached:alpine";
|
[ObservableProperty] private string _defaultMemcachedImage = "memcached:alpine";
|
||||||
[ObservableProperty] private string _defaultQuickChartImage = "ianw/quickchart";
|
[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 _defaultThemeHostPath = "/cms/{abbrev}-cms-theme-custom";
|
||||||
[ObservableProperty] private string _defaultMySqlDbTemplate = "{abbrev}_cms_db";
|
[ObservableProperty] private string _defaultMySqlDbTemplate = "{abbrev}_cms_db";
|
||||||
[ObservableProperty] private string _defaultMySqlUserTemplate = "{abbrev}_cms_user";
|
[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 _bitwardenProjectId = string.Empty;
|
||||||
[ObservableProperty] private string _bitwardenInstanceProjectId = 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 ─────────────────────────────────────
|
// ── Xibo Bootstrap OAuth2 ─────────────────────────────────────
|
||||||
[ObservableProperty] private string _xiboBootstrapClientId = string.Empty;
|
[ObservableProperty] private string _xiboBootstrapClientId = string.Empty;
|
||||||
[ObservableProperty] private string _xiboBootstrapClientSecret = string.Empty;
|
[ObservableProperty] private string _xiboBootstrapClientSecret = string.Empty;
|
||||||
@@ -85,22 +104,27 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
[ObservableProperty] private bool _isBitwardenConfigured;
|
[ObservableProperty] private bool _isBitwardenConfigured;
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task LoadAsync()
|
private Task LoadAsync() => LoadCoreAsync(skipBitwarden: false);
|
||||||
|
|
||||||
|
private async Task LoadCoreAsync(bool skipBitwarden)
|
||||||
{
|
{
|
||||||
IsBusy = true;
|
IsBusy = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// ── Load Bitwarden bootstrap config from IOptions<BitwardenOptions> ──
|
if (!skipBitwarden)
|
||||||
var bwOptions = _services.GetRequiredService<IOptions<BitwardenOptions>>().Value;
|
{
|
||||||
BitwardenIdentityUrl = bwOptions.IdentityUrl;
|
// ── Load Bitwarden bootstrap config (IOptionsMonitor picks up appsettings.json changes) ──
|
||||||
BitwardenApiUrl = bwOptions.ApiUrl;
|
var bwOptions = _services.GetRequiredService<IOptionsMonitor<BitwardenOptions>>().CurrentValue;
|
||||||
BitwardenAccessToken = bwOptions.AccessToken;
|
BitwardenIdentityUrl = bwOptions.IdentityUrl;
|
||||||
BitwardenOrganizationId = bwOptions.OrganizationId;
|
BitwardenApiUrl = bwOptions.ApiUrl;
|
||||||
BitwardenProjectId = bwOptions.ProjectId;
|
BitwardenAccessToken = bwOptions.AccessToken;
|
||||||
BitwardenInstanceProjectId = bwOptions.InstanceProjectId;
|
BitwardenOrganizationId = bwOptions.OrganizationId;
|
||||||
|
BitwardenProjectId = bwOptions.ProjectId;
|
||||||
|
BitwardenInstanceProjectId = bwOptions.InstanceProjectId;
|
||||||
|
}
|
||||||
|
|
||||||
IsBitwardenConfigured = !string.IsNullOrWhiteSpace(bwOptions.AccessToken)
|
IsBitwardenConfigured = !string.IsNullOrWhiteSpace(BitwardenAccessToken)
|
||||||
&& !string.IsNullOrWhiteSpace(bwOptions.OrganizationId);
|
&& !string.IsNullOrWhiteSpace(BitwardenOrganizationId);
|
||||||
|
|
||||||
if (!IsBitwardenConfigured)
|
if (!IsBitwardenConfigured)
|
||||||
{
|
{
|
||||||
@@ -146,7 +170,7 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
DefaultNewtImage = await svc.GetAsync(SettingsService.DefaultNewtImage, "fosrl/newt");
|
DefaultNewtImage = await svc.GetAsync(SettingsService.DefaultNewtImage, "fosrl/newt");
|
||||||
DefaultMemcachedImage = await svc.GetAsync(SettingsService.DefaultMemcachedImage, "memcached:alpine");
|
DefaultMemcachedImage = await svc.GetAsync(SettingsService.DefaultMemcachedImage, "memcached:alpine");
|
||||||
DefaultQuickChartImage = await svc.GetAsync(SettingsService.DefaultQuickChartImage, "ianw/quickchart");
|
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");
|
DefaultThemeHostPath = await svc.GetAsync(SettingsService.DefaultThemeHostPath, "/cms/{abbrev}-cms-theme-custom");
|
||||||
DefaultMySqlDbTemplate = await svc.GetAsync(SettingsService.DefaultMySqlDbTemplate, "{abbrev}_cms_db");
|
DefaultMySqlDbTemplate = await svc.GetAsync(SettingsService.DefaultMySqlDbTemplate, "{abbrev}_cms_db");
|
||||||
DefaultMySqlUserTemplate = await svc.GetAsync(SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms_user");
|
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");
|
DefaultPhpUploadMaxFilesize = await svc.GetAsync(SettingsService.DefaultPhpUploadMaxFilesize, "10G");
|
||||||
DefaultPhpMaxExecutionTime = await svc.GetAsync(SettingsService.DefaultPhpMaxExecutionTime, "600");
|
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
|
// Xibo Bootstrap
|
||||||
XiboBootstrapClientId = await svc.GetAsync(SettingsService.XiboBootstrapClientId, string.Empty);
|
XiboBootstrapClientId = await svc.GetAsync(SettingsService.XiboBootstrapClientId, string.Empty);
|
||||||
XiboBootstrapClientSecret = await svc.GetAsync(SettingsService.XiboBootstrapClientSecret, string.Empty);
|
XiboBootstrapClientSecret = await svc.GetAsync(SettingsService.XiboBootstrapClientSecret, string.Empty);
|
||||||
@@ -171,28 +206,51 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task SaveAsync()
|
private async Task SaveBitwardenLocalAsync()
|
||||||
{
|
{
|
||||||
IsBusy = true;
|
IsBusy = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// ── 1. Save Bitwarden bootstrap config to appsettings.json ──
|
|
||||||
await SaveBitwardenConfigToFileAsync();
|
await SaveBitwardenConfigToFileAsync();
|
||||||
|
|
||||||
// Check if Bitwarden is now configured
|
|
||||||
IsBitwardenConfigured = !string.IsNullOrWhiteSpace(BitwardenAccessToken)
|
IsBitwardenConfigured = !string.IsNullOrWhiteSpace(BitwardenAccessToken)
|
||||||
&& !string.IsNullOrWhiteSpace(BitwardenOrganizationId);
|
&& !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)
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 2. Save all other settings to Bitwarden ──
|
|
||||||
using var scope = _services.CreateScope();
|
using var scope = _services.CreateScope();
|
||||||
var svc = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
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)>
|
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.DefaultPhpUploadMaxFilesize, DefaultPhpUploadMaxFilesize, SettingsService.CatDefaults, false),
|
||||||
(SettingsService.DefaultPhpMaxExecutionTime, DefaultPhpMaxExecutionTime, 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
|
// Xibo Bootstrap
|
||||||
(SettingsService.XiboBootstrapClientId, NullIfEmpty(XiboBootstrapClientId), SettingsService.CatXibo, false),
|
(SettingsService.XiboBootstrapClientId, NullIfEmpty(XiboBootstrapClientId), SettingsService.CatXibo, false),
|
||||||
(SettingsService.XiboBootstrapClientSecret, NullIfEmpty(XiboBootstrapClientSecret), SettingsService.CatXibo, true),
|
(SettingsService.XiboBootstrapClientSecret, NullIfEmpty(XiboBootstrapClientSecret), SettingsService.CatXibo, true),
|
||||||
};
|
};
|
||||||
|
|
||||||
await svc.SaveManyAsync(settings);
|
await svc.SaveManyAsync(settings);
|
||||||
StatusMessage = "Settings saved to Bitwarden.";
|
StatusMessage = "Settings pushed to Bitwarden.";
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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]
|
[RelayCommand]
|
||||||
private async Task TestXiboBootstrapAsync()
|
private async Task TestXiboBootstrapAsync()
|
||||||
{
|
{
|
||||||
@@ -305,7 +499,7 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
using var scope = _services.CreateScope();
|
using var scope = _services.CreateScope();
|
||||||
var xibo = scope.ServiceProvider.GetRequiredService<XiboApiService>();
|
var xibo = scope.ServiceProvider.GetRequiredService<XiboApiService>();
|
||||||
var svc = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
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
|
// 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.";
|
StatusMessage = "Xibo bootstrap credentials saved. Connect test requires a live instance URL — use 'Details' on an instance to verify.";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,12 +14,14 @@
|
|||||||
<!-- Top toolbar -->
|
<!-- Top toolbar -->
|
||||||
<Border DockPanel.Dock="Top" Classes="toolbar" Margin="0,0,0,16">
|
<Border DockPanel.Dock="Top" Classes="toolbar" Margin="0,0,0,16">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
<Button Content="Save All Settings"
|
<Button Content="Push to Bitwarden"
|
||||||
Classes="accent"
|
Classes="accent"
|
||||||
Command="{Binding SaveCommand}"
|
Command="{Binding PushToBitwardenCommand}"
|
||||||
IsEnabled="{Binding !IsBusy}"
|
IsEnabled="{Binding !IsBusy}"
|
||||||
FontWeight="SemiBold" Padding="20,8" />
|
FontWeight="SemiBold" Padding="20,8" />
|
||||||
<Button Content="Reload" Command="{Binding LoadCommand}" IsEnabled="{Binding !IsBusy}" />
|
<Button Content="Pull from Bitwarden"
|
||||||
|
Command="{Binding PullFromBitwardenCommand}"
|
||||||
|
IsEnabled="{Binding !IsBusy}" />
|
||||||
<TextBlock Text="{Binding StatusMessage}" Classes="status"
|
<TextBlock Text="{Binding StatusMessage}" Classes="status"
|
||||||
VerticalAlignment="Center" Margin="6,0,0,0" />
|
VerticalAlignment="Center" Margin="6,0,0,0" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
@@ -72,10 +74,15 @@
|
|||||||
<TextBox Text="{Binding BitwardenInstanceProjectId}"
|
<TextBox Text="{Binding BitwardenInstanceProjectId}"
|
||||||
Watermark="00000000-0000-0000-0000-000000000000 (leave empty to use default project)" />
|
Watermark="00000000-0000-0000-0000-000000000000 (leave empty to use default project)" />
|
||||||
|
|
||||||
<Button Content="Test Bitwarden Connection"
|
<StackPanel Orientation="Horizontal" Spacing="10" Margin="0,6,0,0">
|
||||||
Command="{Binding TestBitwardenConnectionCommand}"
|
<Button Content="Save Bitwarden Config"
|
||||||
IsEnabled="{Binding !IsBusy}"
|
Classes="accent"
|
||||||
Margin="0,6,0,0" />
|
Command="{Binding SaveBitwardenLocalCommand}"
|
||||||
|
IsEnabled="{Binding !IsBusy}" />
|
||||||
|
<Button Content="Test Connection"
|
||||||
|
Command="{Binding TestBitwardenConnectionCommand}"
|
||||||
|
IsEnabled="{Binding !IsBusy}" />
|
||||||
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
@@ -310,6 +317,88 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!-- ═══ Authentik (SAML IdP) ═══ -->
|
||||||
|
<Border Classes="card">
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,0,0,4">
|
||||||
|
<Border Width="4" Height="20" CornerRadius="2" Background="#FB923C" />
|
||||||
|
<TextBlock Text="Authentik (SAML IdP)" FontSize="16" FontWeight="SemiBold"
|
||||||
|
Foreground="#FB923C" VerticalAlignment="Center" />
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock Text="Authentik identity provider settings. A SAML application is automatically provisioned in Authentik for each new instance during post-init, and a settings-custom.php file is deployed."
|
||||||
|
FontSize="12" Foreground="{StaticResource TextMutedBrush}" Margin="0,0,0,6"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
|
||||||
|
<TextBlock Text="Authentik Base URL" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||||
|
<TextBox Text="{Binding AuthentikUrl}"
|
||||||
|
Watermark="https://id.oribi-tech.com" />
|
||||||
|
|
||||||
|
<TextBlock Text="API Token" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||||
|
<TextBox Text="{Binding AuthentikApiKey}" PasswordChar="●"
|
||||||
|
Watermark="Bearer token for /api/v3/" />
|
||||||
|
|
||||||
|
<!-- Save & Test button -->
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="10" Margin="0,8,0,0">
|
||||||
|
<Button Content="Save & Test Connection"
|
||||||
|
Classes="accent"
|
||||||
|
Command="{Binding SaveAndTestAuthentikCommand}"
|
||||||
|
IsEnabled="{Binding !IsAuthentikBusy}"
|
||||||
|
FontWeight="SemiBold" Padding="16,8" />
|
||||||
|
<Button Content="Refresh Dropdowns"
|
||||||
|
Command="{Binding FetchAuthentikDropdownsCommand}"
|
||||||
|
IsEnabled="{Binding !IsAuthentikBusy}"
|
||||||
|
Padding="16,8" />
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock Text="{Binding AuthentikStatusMessage}"
|
||||||
|
FontSize="12" Foreground="{StaticResource TextSecondaryBrush}"
|
||||||
|
TextWrapping="Wrap" Margin="0,2,0,0" />
|
||||||
|
|
||||||
|
<!-- Flow / Keypair dropdowns -->
|
||||||
|
<Border Height="1" Background="{StaticResource BorderSubtleBrush}" Margin="0,12,0,4" />
|
||||||
|
<TextBlock Text="Flows & Keypairs" FontSize="13" FontWeight="SemiBold" Margin="0,4,0,4"
|
||||||
|
Foreground="{StaticResource TextPrimaryBrush}" />
|
||||||
|
<TextBlock Text="These are loaded from your Authentik instance. Save & Test to populate."
|
||||||
|
FontSize="12" Foreground="{StaticResource TextMutedBrush}" Margin="0,0,0,4"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
|
||||||
|
<TextBlock Text="Authorization Flow" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||||
|
<ComboBox ItemsSource="{Binding AuthentikAuthorizationFlows}"
|
||||||
|
SelectedItem="{Binding SelectedAuthorizationFlow}"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
PlaceholderText="(save & test to load flows)">
|
||||||
|
<ComboBox.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:SettingsViewModel">
|
||||||
|
<TextBlock Text="{Binding}" />
|
||||||
|
</DataTemplate>
|
||||||
|
</ComboBox.ItemTemplate>
|
||||||
|
</ComboBox>
|
||||||
|
|
||||||
|
<TextBlock Text="Invalidation Flow" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||||
|
<ComboBox ItemsSource="{Binding AuthentikInvalidationFlows}"
|
||||||
|
SelectedItem="{Binding SelectedInvalidationFlow}"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
PlaceholderText="(save & test to load flows)">
|
||||||
|
<ComboBox.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:SettingsViewModel">
|
||||||
|
<TextBlock Text="{Binding}" />
|
||||||
|
</DataTemplate>
|
||||||
|
</ComboBox.ItemTemplate>
|
||||||
|
</ComboBox>
|
||||||
|
|
||||||
|
<TextBlock Text="Signing Keypair" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||||
|
<ComboBox ItemsSource="{Binding AuthentikKeypairs}"
|
||||||
|
SelectedItem="{Binding SelectedSigningKeypair}"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
PlaceholderText="(save & test to load keypairs)">
|
||||||
|
<ComboBox.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:SettingsViewModel">
|
||||||
|
<TextBlock Text="{Binding}" />
|
||||||
|
</DataTemplate>
|
||||||
|
</ComboBox.ItemTemplate>
|
||||||
|
</ComboBox>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
</StackPanel> <!-- end of IsBitwardenConfigured wrapper -->
|
</StackPanel> <!-- end of IsBitwardenConfigured wrapper -->
|
||||||
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
"Default": "Data Source=otssigns-desktop.db"
|
"Default": "Data Source=otssigns-desktop.db"
|
||||||
},
|
},
|
||||||
"InstanceDefaults": {
|
"InstanceDefaults": {
|
||||||
"CmsServerNameTemplate": "{abbrev}.ots-signs.com",
|
"CmsServerNameTemplate": "app.ots-signs.com",
|
||||||
"ThemeHostPath": "/cms/ots-theme",
|
"ThemeHostPath": "/cms/ots-theme",
|
||||||
"LibraryShareSubPath": "{abbrev}-cms-library",
|
"LibraryShareSubPath": "{abbrev}-cms-library",
|
||||||
"MySqlDatabaseTemplate": "{abbrev}_cms_db",
|
"MySqlDatabaseTemplate": "{abbrev}_cms_db",
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -86,8 +86,7 @@ services:
|
|||||||
NEWT_ID: {{NEWT_ID}}
|
NEWT_ID: {{NEWT_ID}}
|
||||||
NEWT_SECRET: {{NEWT_SECRET}}
|
NEWT_SECRET: {{NEWT_SECRET}}
|
||||||
depends_on:
|
depends_on:
|
||||||
{{ABBREV}}-web:
|
- {{ABBREV}}-web
|
||||||
condition: service_healthy
|
|
||||||
networks:
|
networks:
|
||||||
{{ABBREV}}-net: {}
|
{{ABBREV}}-net: {}
|
||||||
deploy:
|
deploy:
|
||||||
|
|||||||
Reference in New Issue
Block a user