diff --git a/.gitea/workflows/docker-publish.yml b/.gitea/workflows/docker-publish.yml deleted file mode 100644 index d2e3836..0000000 --- a/.gitea/workflows/docker-publish.yml +++ /dev/null @@ -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. }} in Gitea Actions - REGISTRY: ${{ secrets.REGISTRY }} - IMAGE_NAME: ${{ secrets.IMAGE_NAME }} - DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} - DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} diff --git a/.gitignore b/.gitignore index 1c32989..8865743 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,4 @@ docker-compose.override.yml # Ignore appsettings development files (if you keep secrets locally) appsettings.Development.json +.template-cache/ \ No newline at end of file diff --git a/.template-cache/053604496cfa3867 b/.template-cache/053604496cfa3867 index a6ab3c2..eaf06cf 160000 --- a/.template-cache/053604496cfa3867 +++ b/.template-cache/053604496cfa3867 @@ -1 +1 @@ -Subproject commit a6ab3c254bee92183ac6b3af9405658b0a02e1d2 +Subproject commit eaf06cf6247b58eb638f4d2ebc774f607b3d2fd7 diff --git a/.template-cache/2dc03e2b2b45fef3 b/.template-cache/2dc03e2b2b45fef3 index a6ab3c2..eaf06cf 160000 --- a/.template-cache/2dc03e2b2b45fef3 +++ b/.template-cache/2dc03e2b2b45fef3 @@ -1 +1 @@ -Subproject commit a6ab3c254bee92183ac6b3af9405658b0a02e1d2 +Subproject commit eaf06cf6247b58eb638f4d2ebc774f607b3d2fd7 diff --git a/OTSSignsOrchestrator.Core/Configuration/AppOptions.cs b/OTSSignsOrchestrator.Core/Configuration/AppOptions.cs index dd7bb4e..3b245cc 100644 --- a/OTSSignsOrchestrator.Core/Configuration/AppOptions.cs +++ b/OTSSignsOrchestrator.Core/Configuration/AppOptions.cs @@ -82,7 +82,7 @@ public class InstanceDefaultsOptions public string? TemplateRepoPat { get; set; } /// Template for CMS server hostname. Use {abbrev} as placeholder. - public string CmsServerNameTemplate { get; set; } = "{abbrev}.ots-signs.com"; + public string CmsServerNameTemplate { get; set; } = "app.ots-signs.com"; public string SmtpServer { get; set; } = string.Empty; public string SmtpUsername { get; set; } = string.Empty; diff --git a/OTSSignsOrchestrator.Core/Models/DTOs/AuthentikFlowItem.cs b/OTSSignsOrchestrator.Core/Models/DTOs/AuthentikFlowItem.cs new file mode 100644 index 0000000..3aaf4b6 --- /dev/null +++ b/OTSSignsOrchestrator.Core/Models/DTOs/AuthentikFlowItem.cs @@ -0,0 +1,17 @@ +namespace OTSSignsOrchestrator.Core.Models.DTOs; + +/// +/// Represents an Authentik flow for display in the Settings UI. +/// +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; + + /// Display text for ComboBox: "slug — Name". + public string DisplayText => $"{Slug} — {Name}"; + + public override string ToString() => DisplayText; +} diff --git a/OTSSignsOrchestrator.Core/Models/DTOs/AuthentikKeypairItem.cs b/OTSSignsOrchestrator.Core/Models/DTOs/AuthentikKeypairItem.cs new file mode 100644 index 0000000..2caa674 --- /dev/null +++ b/OTSSignsOrchestrator.Core/Models/DTOs/AuthentikKeypairItem.cs @@ -0,0 +1,15 @@ +namespace OTSSignsOrchestrator.Core.Models.DTOs; + +/// +/// Represents an Authentik certificate keypair for display in the Settings UI. +/// +public class AuthentikKeypairItem +{ + public string Pk { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + + /// Display text for ComboBox. + public string DisplayText => $"{Name} ({Pk[..Math.Min(8, Pk.Length)]})"; + + public override string ToString() => DisplayText; +} diff --git a/OTSSignsOrchestrator.Core/Services/AuthentikService.cs b/OTSSignsOrchestrator.Core/Services/AuthentikService.cs index c149374..8ea6b22 100644 --- a/OTSSignsOrchestrator.Core/Services/AuthentikService.cs +++ b/OTSSignsOrchestrator.Core/Services/AuthentikService.cs @@ -1,5 +1,6 @@ using System.Net.Http.Headers; using System.Net.Http.Json; +using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using System.Xml.Linq; @@ -56,6 +57,7 @@ public class AuthentikService : IAuthentikService var (baseUrl, client) = await CreateAuthenticatedClientAsync(); var slug = $"ds-{instanceAbbrev}"; + var providerName = $"OTS Signs — {instanceAbbrev.ToUpperInvariant()} (SAML)"; var samlBaseUrl = instanceBaseUrl.TrimEnd('/') + "/saml"; // ── 1. Check if application already exists ──────────────────────── @@ -78,15 +80,31 @@ public class AuthentikService : IAuthentikService SettingsService.AuthentikInvalidationFlowSlug, "default-provider-invalidation-flow", ct); - // ── 3. Create SAML provider ─────────────────────────────────── - providerId = await CreateSamlProviderAsync(client, baseUrl, - instanceAbbrev, samlBaseUrl, authFlowUuid, invalidFlowUuid, ct); + // ── 3. Find or create SAML provider ────────────────────────── + var existingSamlProvider = await TryGetExistingSamlProviderAsync(client, providerName, ct); + if (existingSamlProvider.HasValue) + { + _logger.LogInformation("[Authentik] SAML provider '{Name}' already exists (id={Id}), reusing", + providerName, existingSamlProvider.Value); + providerId = existingSamlProvider.Value; + } + else + { + providerId = await CreateSamlProviderAsync(client, baseUrl, + instanceAbbrev, samlBaseUrl, authFlowUuid, invalidFlowUuid, ct); + } // ── 4. Create application linked to provider ────────────────── await CreateApplicationAsync(client, baseUrl, instanceAbbrev, slug, providerId, ct); } - // ── 5. Fetch and parse metadata ─────────────────────────────────── + // ── 5. Ensure provider has a signing keypair (required for metadata) ── + await EnsureProviderHasSigningKeypairAsync(client, providerId, ct); + + // ── 5b. Ensure property mappings are attached (required for valid SAML responses) ── + await EnsureProviderHasPropertyMappingsAsync(client, providerId, ct); + + // ── 6. Fetch and parse metadata ─────────────────────────────────── var config = await FetchAndParseMetadataAsync(client, baseUrl, providerId, ct); config.ApplicationSlug = slug; @@ -97,14 +115,75 @@ public class AuthentikService : IAuthentikService return config; } + // ───────────────────────────────────────────────────────────────────────── + // Settings UI helpers + // ───────────────────────────────────────────────────────────────────────── + + /// + 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); + } + } + + /// + public async Task> 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>(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(); + } + + /// + public async Task> 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>(cancellationToken: ct); + return json?.Results?.Select(k => new AuthentikKeypairItem + { + Pk = k.Pk, + Name = k.Name, + }).OrderBy(k => k.Name).ToList() ?? new(); + } + // ───────────────────────────────────────────────────────────────────────── // HTTP client setup // ───────────────────────────────────────────────────────────────────────── - private async Task<(string BaseUrl, HttpClient Client)> CreateAuthenticatedClientAsync() + private async Task<(string BaseUrl, HttpClient Client)> CreateAuthenticatedClientAsync( + string? overrideUrl = null, string? overrideApiKey = null) { - var authentikUrl = await _settings.GetAsync(SettingsService.AuthentikUrl); - var apiKey = await _settings.GetAsync(SettingsService.AuthentikApiKey); + var authentikUrl = overrideUrl ?? await _settings.GetAsync(SettingsService.AuthentikUrl); + var apiKey = overrideApiKey ?? await _settings.GetAsync(SettingsService.AuthentikApiKey); if (string.IsNullOrWhiteSpace(authentikUrl)) throw new InvalidOperationException("Authentik URL is not configured. Set it in Settings → Authentik."); @@ -133,10 +212,22 @@ public class AuthentikService : IAuthentikService if (!resp.IsSuccessStatusCode) return null; var json = await resp.Content.ReadFromJsonAsync>(cancellationToken: ct); + + // Authentik's ?slug= filter may do a partial/contains match, + // so verify the slug matches exactly on the client side. + var app = json?.Results?.FirstOrDefault(a => + string.Equals(a.Slug, slug, StringComparison.OrdinalIgnoreCase)); + + if (app?.Provider != null) + { + return app.Provider; + } + if (json?.Results is { Count: > 0 }) { - var app = json.Results[0]; - return app.Provider; + _logger.LogDebug( + "[Authentik] API returned {Count} application(s) for slug query '{Slug}', but none matched exactly. Slugs returned: {Slugs}", + json.Results.Count, slug, string.Join(", ", json.Results.Select(a => a.Slug))); } } catch (Exception ex) @@ -147,6 +238,273 @@ public class AuthentikService : IAuthentikService return null; } + // ───────────────────────────────────────────────────────────────────────── + // Check for existing SAML provider (orphaned from a previous failed run) + // ───────────────────────────────────────────────────────────────────────── + + private async Task 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>(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 + // ───────────────────────────────────────────────────────────────────────── + + /// + /// 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. + /// + private async Task 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>(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 + // ───────────────────────────────────────────────────────────────────────── + + /// + /// 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. + /// + private async Task> 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>(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(); + } + + // ───────────────────────────────────────────────────────────────────────── + // Ensure provider has a signing keypair + // ───────────────────────────────────────────────────────────────────────── + + /// + /// 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. + /// + 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 + { + ["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); + } + } + + /// + /// 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. + /// + 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 + { + ["property_mappings"] = mappingIds, + }); + var patchContent = new StringContent(patchPayload, Encoding.UTF8, "application/json"); + var patchReq = new HttpRequestMessage(HttpMethod.Patch, $"/api/v3/providers/saml/{providerId}/") + { + Content = patchContent, + }; + + var patchResp = await client.SendAsync(patchReq, ct); + if (patchResp.IsSuccessStatusCode) + { + _logger.LogInformation("[Authentik] Property mappings patched onto provider {Id}", providerId); + } + else + { + var err = await patchResp.Content.ReadAsStringAsync(ct); + _logger.LogWarning("[Authentik] Failed to patch property mappings onto provider {Id} (HTTP {Status}): {Error}", + providerId, (int)patchResp.StatusCode, err); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[Authentik] Could not ensure property mappings for provider {Id}", providerId); + } + } + // ───────────────────────────────────────────────────────────────────────── // Flow resolution // ───────────────────────────────────────────────────────────────────────── @@ -168,15 +526,26 @@ public class AuthentikService : IAuthentikService _logger.LogDebug("[Authentik] Resolving flow UUID for slug '{Slug}'", slug); - var resp = await client.GetAsync($"/api/v3/flows/instances/?slug={slug}", ct); + // Fetch all flows and filter client-side — some Authentik versions don't + // support the ?slug= query parameter or return empty results despite the + // flow existing. + var resp = await client.GetAsync("/api/v3/flows/instances/?page_size=200", ct); resp.EnsureSuccessStatusCode(); var json = await resp.Content.ReadFromJsonAsync>(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(); + _logger.LogWarning("[Authentik] Flow '{Slug}' not found. Available flows: {Flows}", + slug, string.Join(", ", available)); throw new InvalidOperationException( $"Authentik flow '{slug}' not found. Ensure the flow exists or configure the correct slug in Settings → Authentik."); + } - var uuid = json.Results[0].Pk; + var uuid = match.Pk; // Cache for subsequent calls if (settingsKey == SettingsService.AuthentikAuthorizationFlowSlug) @@ -206,22 +575,48 @@ public class AuthentikService : IAuthentikService ["invalidation_flow"] = invalidFlowUuid, ["acs_url"] = $"{samlBaseUrl}/acs", ["sp_binding"] = "post", - ["issuer"] = "authentik", + ["issuer"] = $"ds-{abbrev}", ["audience"] = $"{samlBaseUrl}/metadata", ["default_relay_state"] = "", - ["name_id_mapping"] = (object)null!, // use default + ["sls_url"] = $"{samlBaseUrl}/sls", + ["sls_binding"] = "redirect", }; - // Optionally add SLO URL - payload["sls_url"] = $"{samlBaseUrl}/sls"; - payload["sls_binding"] = "redirect"; + // Attach SAML property mappings so attributes (username, email, etc.) + // are included in the response. Without these Authentik sends an empty + // which fails strict schema validation in php-saml. + var mappingIds = await ResolveSamlPropertyMappingIdsAsync(client, ct); + if (mappingIds.Count > 0) + { + payload["property_mappings"] = mappingIds; + _logger.LogInformation("[Authentik] Attaching {Count} SAML property mapping(s) to provider", mappingIds.Count); + } + else + _logger.LogWarning("[Authentik] No SAML property mappings found — SAML responses may fail schema validation"); - // Optionally attach signing keypair + // Attach signing keypair — required for metadata generation. + // Use the configured keypair, or auto-detect one from Authentik. var signingKpId = await _settings.GetAsync(SettingsService.AuthentikSigningKeypairId); + if (string.IsNullOrWhiteSpace(signingKpId)) + { + _logger.LogDebug("[Authentik] No signing keypair configured — auto-detecting from Authentik"); + signingKpId = await ResolveDefaultKeypairAsync(client, ct); + } if (!string.IsNullOrWhiteSpace(signingKpId)) + { payload["signing_kp"] = signingKpId; + // Authentik requires at least one of these when a signing keypair is set + payload["sign_assertion"] = true; + payload["sign_response"] = true; + } + else + _logger.LogWarning("[Authentik] No signing keypair found — metadata generation may fail"); - var resp = await client.PostAsJsonAsync("/api/v3/providers/saml/", payload, ct); + var jsonBody = JsonSerializer.Serialize(payload); + _logger.LogDebug("[Authentik] SAML provider request body: {Body}", jsonBody); + var content = new StringContent(jsonBody, Encoding.UTF8, "application/json"); + + var resp = await client.PostAsync("/api/v3/providers/saml/", content, ct); if (!resp.IsSuccessStatusCode) { @@ -256,7 +651,11 @@ public class AuthentikService : IAuthentikService ["provider"] = providerId, }; - var resp = await client.PostAsJsonAsync("/api/v3/core/applications/", payload, ct); + var jsonBody = JsonSerializer.Serialize(payload); + _logger.LogDebug("[Authentik] Application request body: {Body}", jsonBody); + var content = new StringContent(jsonBody, Encoding.UTF8, "application/json"); + + var resp = await client.PostAsync("/api/v3/core/applications/", content, ct); if (!resp.IsSuccessStatusCode) { @@ -277,16 +676,215 @@ public class AuthentikService : IAuthentikService { _logger.LogDebug("[Authentik] Fetching SAML metadata for provider {ProviderId}", providerId); - // Request XML metadata (override Accept header for this call) - var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v3/providers/saml/{providerId}/metadata/?download"); - request.Headers.Accept.Clear(); - request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/xml")); + // Retry a few times — Authentik may need a moment after provider creation + const int maxRetries = 3; + for (int attempt = 1; attempt <= maxRetries; attempt++) + { + // The API endpoint returns JSON: { "metadata": "", "download_url": "..." } + var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v3/providers/saml/{providerId}/metadata/"); + request.Headers.Accept.Clear(); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); - var resp = await client.SendAsync(request, ct); - resp.EnsureSuccessStatusCode(); + var resp = await client.SendAsync(request, ct); - var xml = await resp.Content.ReadAsStringAsync(ct); - return ParseMetadataXml(xml, providerId); + if (resp.IsSuccessStatusCode) + { + var jsonBody = await resp.Content.ReadAsStringAsync(ct); + using var doc = JsonDocument.Parse(jsonBody); + + if (doc.RootElement.TryGetProperty("metadata", out var metadataProp)) + { + var xml = metadataProp.GetString(); + if (!string.IsNullOrWhiteSpace(xml)) + return ParseMetadataXml(xml, providerId); + } + + _logger.LogWarning( + "[Authentik] Metadata response for provider {Id} missing 'metadata' field: {Body}", + providerId, jsonBody); + } + else + { + var body = await resp.Content.ReadAsStringAsync(ct); + _logger.LogWarning( + "[Authentik] Metadata fetch attempt {Attempt}/{Max} failed (HTTP {Status}): {Body}", + attempt, maxRetries, (int)resp.StatusCode, + body.Length > 500 ? body[..500] + "…(truncated)" : body); + } + + if (attempt < maxRetries) + await Task.Delay(TimeSpan.FromSeconds(2 * attempt), ct); + } + + // ── Fallback: assemble config from provider detail + keypair cert ── + // Authentik's metadata endpoint can return 500 in some versions. + // All the data we need is available from other endpoints. + _logger.LogWarning( + "[Authentik] Metadata endpoint failed after {Max} attempts — assembling config from provider detail + keypair", + maxRetries); + + return await BuildConfigFromProviderDetailAsync(client, baseUrl, providerId, ct); + } + + /// + /// 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 /metadata/ endpoint returns an error. + /// + private async Task 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; + } + + /// + /// Fetches the PEM certificate from Authentik's keypair endpoints and + /// returns the base64-encoded X.509 body (no PEM headers). + /// Tries /view_certificate/ first, then falls back to the regular + /// keypair detail and certificate_data. + /// + private async Task 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 EnumeratePropertyNames(JsonElement el) + { + if (el.ValueKind == JsonValueKind.Object) + { + foreach (var p in el.EnumerateObject()) + yield return p.Name; + } } private AuthentikSamlConfig ParseMetadataXml(string xml, int providerId) @@ -367,6 +965,30 @@ public class AuthentikService : IAuthentikService public string Name { get; set; } = string.Empty; } + private class AuthentikFlowDetailed + { + [JsonPropertyName("pk")] + public string Pk { get; set; } = string.Empty; + + [JsonPropertyName("slug")] + public string Slug { get; set; } = string.Empty; + + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("designation")] + public string Designation { get; set; } = string.Empty; + } + + private class AuthentikKeypairDetailed + { + [JsonPropertyName("pk")] + public string Pk { get; set; } = string.Empty; + + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + } + private class AuthentikSamlProvider { [JsonPropertyName("pk")] @@ -399,4 +1021,19 @@ public class AuthentikService : IAuthentikService [JsonPropertyName("provider")] public int? Provider { get; set; } } + + private class AuthentikPropertyMapping + { + [JsonPropertyName("pk")] + public string Pk { get; set; } = string.Empty; + + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + /// + /// Non-null for built-in managed mappings, e.g. "goauthentik.io/providers/saml/upn". + /// + [JsonPropertyName("managed")] + public string? Managed { get; set; } + } } diff --git a/OTSSignsOrchestrator.Core/Services/ComposeRenderService.cs b/OTSSignsOrchestrator.Core/Services/ComposeRenderService.cs index 8549d13..be85272 100644 --- a/OTSSignsOrchestrator.Core/Services/ComposeRenderService.cs +++ b/OTSSignsOrchestrator.Core/Services/ComposeRenderService.cs @@ -246,8 +246,7 @@ public class ComposeRenderService NEWT_ID: {{NEWT_ID}} NEWT_SECRET: {{NEWT_SECRET}} depends_on: - {{ABBREV}}-web: - condition: service_healthy + - {{ABBREV}}-web networks: {{ABBREV}}-net: {} deploy: diff --git a/OTSSignsOrchestrator.Core/Services/IAuthentikService.cs b/OTSSignsOrchestrator.Core/Services/IAuthentikService.cs index 79c7eda..fbb84f7 100644 --- a/OTSSignsOrchestrator.Core/Services/IAuthentikService.cs +++ b/OTSSignsOrchestrator.Core/Services/IAuthentikService.cs @@ -13,12 +13,33 @@ public interface IAuthentikService /// then fetches the IdP metadata (entity ID, x509 cert, SSO/SLO URLs). /// If the application already exists (by slug), returns its existing metadata. /// - /// Short customer abbreviation (used in naming). - /// Full base URL of the Xibo instance (e.g. https://app.ots-signs.com/demo). - /// Cancellation token. - /// IdP metadata needed for the SAML PHP configuration. Task ProvisionSamlAsync( string instanceAbbrev, string instanceBaseUrl, CancellationToken ct = default); + + /// + /// Tests the connection to Authentik by fetching the current user. + /// Optionally accepts override URL/key for testing before saving. + /// + Task<(bool Success, string Message)> TestConnectionAsync( + string? overrideUrl = null, + string? overrideApiKey = null, + CancellationToken ct = default); + + /// + /// Returns all available flows from Authentik. + /// + Task> ListFlowsAsync( + string? overrideUrl = null, + string? overrideApiKey = null, + CancellationToken ct = default); + + /// + /// Returns all certificate keypairs from Authentik. + /// + Task> ListKeypairsAsync( + string? overrideUrl = null, + string? overrideApiKey = null, + CancellationToken ct = default); } diff --git a/OTSSignsOrchestrator.Core/Services/InstanceService.cs b/OTSSignsOrchestrator.Core/Services/InstanceService.cs index 4aac016..ffea94b 100644 --- a/OTSSignsOrchestrator.Core/Services/InstanceService.cs +++ b/OTSSignsOrchestrator.Core/Services/InstanceService.cs @@ -153,7 +153,7 @@ public class InstanceService var mySqlDbName = (await _settings.GetAsync(SettingsService.DefaultMySqlDbTemplate, "{abbrev}_cms_db")).Replace("{abbrev}", abbrev); var mySqlUser = mySqlUserName; - var cmsServerName = (await _settings.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "{abbrev}.ots-signs.com")).Replace("{abbrev}", abbrev); + var cmsServerName = (await _settings.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "app.ots-signs.com")).Replace("{abbrev}", abbrev); var themePath = (await _settings.GetAsync(SettingsService.DefaultThemeHostPath, "/cms/ots-theme")).Replace("{abbrev}", abbrev); var smtpServer = await _settings.GetAsync(SettingsService.SmtpServer, string.Empty); @@ -249,6 +249,12 @@ public class InstanceService + "(2) NFS export has root_squash enabled — set 'No mapping' / no_root_squash on the NFS server."); } + // ── 5c. Write settings-custom.php to NFS volume (SAML config) ──────── + // This must happen before the stack is deployed so Xibo starts with SAML + // authentication already configured. + var instanceUrlForSaml = $"https://{cmsServerName}/{abbrev}"; + await _postInit.DeploySamlConfigurationAsync(abbrev, instanceUrlForSaml, _settings, default); + // ── 6. Deploy stack ───────────────────────────────────────────── var deployResult = await _docker.DeployStackAsync(stackName, composeYaml); if (!deployResult.Success) @@ -341,7 +347,7 @@ public class InstanceService var pangolinEndpoint = await _settings.GetAsync(SettingsService.PangolinEndpoint, "https://app.pangolin.net"); var cmsServerName = dto.CmsServerName - ?? (await _settings.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "{abbrev}.ots-signs.com")).Replace("{abbrev}", abbrev); + ?? (await _settings.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "app.ots-signs.com")).Replace("{abbrev}", abbrev); var hostHttpPort = dto.HostHttpPort ?? 80; var themePath = dto.ThemeHostPath ?? (await _settings.GetAsync(SettingsService.DefaultThemeHostPath, "/cms/ots-theme")).Replace("{abbrev}", abbrev); diff --git a/OTSSignsOrchestrator.Core/Services/PostInstanceInitService.cs b/OTSSignsOrchestrator.Core/Services/PostInstanceInitService.cs index 309b23f..eea95d2 100644 --- a/OTSSignsOrchestrator.Core/Services/PostInstanceInitService.cs +++ b/OTSSignsOrchestrator.Core/Services/PostInstanceInitService.cs @@ -107,9 +107,6 @@ public class PostInstanceInitService _logger.LogInformation("[PostInit] Setting Xibo theme to 'otssigns'"); await xibo.SetThemeAsync(instanceUrl, accessToken, "otssigns"); - // ── 6a. Deploy SAML configuration ───────────────────────────────── - await DeploySamlConfigurationAsync(abbrev, instanceUrl, settings, ct); - // ── 7. Store credentials in Bitwarden ───────────────────────────── _logger.LogInformation("[PostInit] Storing credentials in Bitwarden"); @@ -200,9 +197,6 @@ public class PostInstanceInitService _logger.LogInformation("[PostInit] Setting theme to 'otssigns'"); await xibo.SetThemeAsync(instanceUrl, accessToken, "otssigns"); - // ── 5a. Deploy SAML configuration ───────────────────────────────── - await DeploySamlConfigurationAsync(abbrev, instanceUrl, settings, ct); - // ── 6. Store admin password in Bitwarden ────────────────────────── _logger.LogInformation("[PostInit] Storing credentials in Bitwarden"); var adminSecretId = await bws.CreateInstanceSecretAsync( @@ -349,9 +343,11 @@ public class PostInstanceInitService /// /// Provisions a SAML application in Authentik, renders the settings-custom.php template, /// and writes the rendered file to the instance's NFS-backed cms-custom volume. - /// Errors are logged but do not fail the overall post-init process. + /// The template is resolved from (a) the git repo cache, or (b) the local bundled + /// templates/ directory shipped with the application. + /// Errors are logged but do not fail the overall deployment. /// - private async Task DeploySamlConfigurationAsync( + public async Task DeploySamlConfigurationAsync( string abbrev, string instanceUrl, SettingsService settings, @@ -366,36 +362,80 @@ public class PostInstanceInitService var git = scope.ServiceProvider.GetRequiredService(); var docker = scope.ServiceProvider.GetRequiredService(); - // ── 1. Fetch template from git repo ─────────────────────────────── + // ── 1. Locate settings-custom.php.template ──────────────────────── + string? templateContent = null; + + // 1a. Try git repo cache first var repoUrl = await settings.GetAsync(SettingsService.GitRepoUrl); var repoPat = await settings.GetAsync(SettingsService.GitRepoPat); - if (string.IsNullOrWhiteSpace(repoUrl)) - throw new InvalidOperationException("Git repository URL is not configured."); + if (!string.IsNullOrWhiteSpace(repoUrl)) + { + try + { + var templateConfig = await git.FetchAsync(repoUrl, repoPat); + var gitPath = Path.Combine(templateConfig.CacheDir, "settings-custom.php.template"); + if (File.Exists(gitPath)) + { + templateContent = await File.ReadAllTextAsync(gitPath, ct); + _logger.LogInformation("[PostInit] Using template from git repo cache: {Path}", gitPath); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[PostInit] Could not fetch template from git — trying local fallback"); + } + } - var templateConfig = await git.FetchAsync(repoUrl, repoPat); - var templatePath = Path.Combine(templateConfig.CacheDir, "settings-custom.php.template"); + // 1b. Fall back to local templates/ directory (bundled with app) + if (templateContent == null) + { + var candidates = new[] + { + Path.Combine(AppContext.BaseDirectory, "templates", "settings-custom.php.template"), + Path.Combine(Directory.GetCurrentDirectory(), "templates", "settings-custom.php.template"), + }; - if (!File.Exists(templatePath)) + foreach (var candidate in candidates) + { + if (File.Exists(candidate)) + { + templateContent = await File.ReadAllTextAsync(candidate, ct); + _logger.LogInformation("[PostInit] Using local template: {Path}", candidate); + break; + } + } + } + + if (templateContent == null) { _logger.LogWarning( - "[PostInit] settings-custom.php.template not found in git repo — skipping SAML deployment"); + "[PostInit] settings-custom.php.template not found in git repo or local templates/ — skipping SAML deployment"); return; } - var templateContent = await File.ReadAllTextAsync(templatePath, ct); - // ── 2. Provision Authentik SAML application ─────────────────────── var samlBaseUrl = instanceUrl.TrimEnd('/') + "/saml"; - var samlConfig = await authentik.ProvisionSamlAsync(abbrev, instanceUrl, ct); + Models.DTOs.AuthentikSamlConfig? samlConfig = null; + try + { + samlConfig = await authentik.ProvisionSamlAsync(abbrev, instanceUrl, ct); + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "[PostInit] Authentik provisioning failed for {Abbrev} — skipping SAML config deployment to avoid broken Xibo instance", + abbrev); + return; // Do NOT write a settings-custom.php with empty IdP values — it will crash Xibo + } // ── 3. Render template ──────────────────────────────────────────── var rendered = templateContent .Replace("{{SAML_BASE_URL}}", samlBaseUrl) .Replace("{{SAML_SP_ENTITY_ID}}", $"{samlBaseUrl}/metadata") - .Replace("{{AUTHENTIK_IDP_ENTITY_ID}}", samlConfig.IdpEntityId) - .Replace("{{AUTHENTIK_SSO_URL}}", samlConfig.SsoUrlRedirect) - .Replace("{{AUTHENTIK_SLO_URL}}", samlConfig.SloUrlRedirect) - .Replace("{{AUTHENTIK_IDP_X509_CERT}}", samlConfig.IdpX509Cert); + .Replace("{{AUTHENTIK_IDP_ENTITY_ID}}", samlConfig?.IdpEntityId ?? "") + .Replace("{{AUTHENTIK_SSO_URL}}", samlConfig?.SsoUrlRedirect ?? "") + .Replace("{{AUTHENTIK_SLO_URL}}", samlConfig?.SloUrlRedirect ?? "") + .Replace("{{AUTHENTIK_IDP_X509_CERT}}", samlConfig?.IdpX509Cert ?? ""); // ── 4. Write rendered file to NFS volume ────────────────────────── var nfsServer = await settings.GetAsync(SettingsService.NfsServer); @@ -415,8 +455,11 @@ public class PostInstanceInitService throw new InvalidOperationException($"Failed to write settings-custom.php to NFS: {error}"); _logger.LogInformation( - "[PostInit] SAML configuration deployed for {Abbrev} (Authentik provider={ProviderId})", - abbrev, samlConfig.ProviderId); + "[PostInit] SAML configuration deployed for {Abbrev}{ProviderInfo}", + abbrev, + samlConfig != null + ? $" (Authentik provider={samlConfig.ProviderId})" + : " (without Authentik — needs manual IdP config)"); } catch (Exception ex) { diff --git a/OTSSignsOrchestrator.Desktop/OTSSignsOrchestrator.Desktop.csproj b/OTSSignsOrchestrator.Desktop/OTSSignsOrchestrator.Desktop.csproj index dae799d..b953af4 100644 --- a/OTSSignsOrchestrator.Desktop/OTSSignsOrchestrator.Desktop.csproj +++ b/OTSSignsOrchestrator.Desktop/OTSSignsOrchestrator.Desktop.csproj @@ -49,4 +49,11 @@ + + + PreserveNewest + templates/settings-custom.php.template + + + diff --git a/OTSSignsOrchestrator.Desktop/Services/SshDockerCliService.cs b/OTSSignsOrchestrator.Desktop/Services/SshDockerCliService.cs index ec8302c..77f356d 100644 --- a/OTSSignsOrchestrator.Desktop/Services/SshDockerCliService.cs +++ b/OTSSignsOrchestrator.Desktop/Services/SshDockerCliService.cs @@ -41,6 +41,70 @@ public class SshDockerCliService : IDockerCliService public SshHost? CurrentHost => _currentHost; + private void EnsureHost() + { + if (_currentHost == null) + throw new InvalidOperationException("No SSH host configured. Call SetHost() before using Docker commands."); + } + + /// + /// Escape password for safe use in shell scripts with proper quoting. + /// Uses printf-safe escaping to avoid newline injection and special character issues. + /// + 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; + } + + /// + /// Test if the current host's password works with sudo by running a no-op sudo command. + /// + 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 DeployStackAsync(string stackName, string composeYaml, bool resolveImage = false) { EnsureHost(); @@ -184,12 +248,22 @@ public class SshDockerCliService : IDockerCliService // 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" // 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 = $""" set -e MNT=$(mktemp -d) - sudo mount -t nfs -o addr={nfsServer},nfsvers=4,proto=tcp,soft,timeo=50,retrans=2 {nfsServer}:/{exportPath} "$MNT" - sudo mkdir -p {mkdirTargets} - sudo umount "$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" + printf '%s\n' '{escapedPassword}' | sudo -S mkdir -p {mkdirTargets} + printf '%s\n' '{escapedPassword}' | sudo -S umount "$MNT" rmdir "$MNT" """; @@ -229,12 +303,22 @@ public class SshDockerCliService : IDockerCliService var folderList = folderNames.Select(f => $"\"$MNT{subPath}/{f}\"").ToList(); 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 = $""" set -e MNT=$(mktemp -d) - sudo mount -t nfs -o addr={nfsServer},nfsvers=4,proto=tcp,soft,timeo=50,retrans=2 {nfsServer}:/{exportPath} "$MNT" - sudo mkdir -p {mkdirTargets} - sudo umount "$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" + printf '%s\n' '{escapedPassword}' | sudo -S mkdir -p {mkdirTargets} + printf '%s\n' '{escapedPassword}' | sudo -S umount "$MNT" rmdir "$MNT" """; @@ -271,22 +355,44 @@ public class SshDockerCliService : IDockerCliService var subFolder = (nfsExportFolder ?? string.Empty).Trim('/'); 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 parentDir = $"$(dirname \"{targetPath}\")"; - // Escape content for heredoc (replace any literal EOF that might appear in content) - var safeContent = content.Replace("'", "'\\''"); + // Properly escape password for shell use (handle special characters like single quotes) + 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 = $""" set -e + TMPFILE=$(mktemp) + echo '{base64Content}' | base64 -d > "$TMPFILE" MNT=$(mktemp -d) - sudo mount -t nfs -o addr={nfsServer},nfsvers=4,proto=tcp,soft,timeo=50,retrans=2 {nfsServer}:/{exportPath} "$MNT" - sudo mkdir -p {parentDir} - sudo tee "{targetPath}" > /dev/null << 'OTSSIGNS_EOF' - {content} - OTSSIGNS_EOF - sudo umount "$MNT" + printf '%s\n' '{escapedPassword}' | sudo -S mount -t nfs -o addr={nfsServer},nfsvers=4,proto=tcp,soft,timeo=50,retrans=2 {nfsServer}:/{exportPath} "$MNT" + printf '%s\n' '{escapedPassword}' | sudo -S mkdir -p {parentDir} + printf '%s\n' '{escapedPassword}' | sudo -S cp "$TMPFILE" "{targetPath}" + rm -f "$TMPFILE" + printf '%s\n' '{escapedPassword}' | sudo -S umount "$MNT" rmdir "$MNT" """; @@ -609,12 +715,6 @@ public class SshDockerCliService : IDockerCliService 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 RemoveStackVolumesAsync(string stackName) { EnsureHost(); diff --git a/OTSSignsOrchestrator.Desktop/ViewModels/CreateInstanceViewModel.cs b/OTSSignsOrchestrator.Desktop/ViewModels/CreateInstanceViewModel.cs index 6c98797..e3b1a90 100644 --- a/OTSSignsOrchestrator.Desktop/ViewModels/CreateInstanceViewModel.cs +++ b/OTSSignsOrchestrator.Desktop/ViewModels/CreateInstanceViewModel.cs @@ -169,7 +169,7 @@ public partial class CreateInstanceViewModel : ObservableObject var mySqlDbName = (await settings.GetAsync(SettingsService.DefaultMySqlDbTemplate, "{abbrev}_cms_db")).Replace("{abbrev}", abbrev); var mySqlUser = (await settings.GetAsync(SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms_user")).Replace("{abbrev}", abbrev); - var cmsServerName = (await settings.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "{abbrev}.ots-signs.com")).Replace("{abbrev}", abbrev); + var cmsServerName = (await settings.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "app.ots-signs.com")).Replace("{abbrev}", abbrev); var themePath = (await settings.GetAsync(SettingsService.DefaultThemeHostPath, "/cms/ots-theme")).Replace("{abbrev}", abbrev); var smtpServer = await settings.GetAsync(SettingsService.SmtpServer, string.Empty); diff --git a/OTSSignsOrchestrator.Desktop/ViewModels/InstanceDetailsViewModel.cs b/OTSSignsOrchestrator.Desktop/ViewModels/InstanceDetailsViewModel.cs index 2141a8a..32955a1 100644 --- a/OTSSignsOrchestrator.Desktop/ViewModels/InstanceDetailsViewModel.cs +++ b/OTSSignsOrchestrator.Desktop/ViewModels/InstanceDetailsViewModel.cs @@ -80,7 +80,7 @@ public partial class InstanceDetailsViewModel : ObservableObject // Derive the instance URL from the CMS server name template var serverTemplate = await settings.GetAsync( - SettingsService.DefaultCmsServerNameTemplate, "{abbrev}.ots-signs.com"); + SettingsService.DefaultCmsServerNameTemplate, "app.ots-signs.com"); var serverName = serverTemplate.Replace("{abbrev}", instance.CustomerAbbrev); InstanceUrl = $"https://{serverName}/{instance.CustomerAbbrev.Trim().ToLowerInvariant()}"; diff --git a/OTSSignsOrchestrator.Desktop/ViewModels/SettingsViewModel.cs b/OTSSignsOrchestrator.Desktop/ViewModels/SettingsViewModel.cs index 00eb848..e8add32 100644 --- a/OTSSignsOrchestrator.Desktop/ViewModels/SettingsViewModel.cs +++ b/OTSSignsOrchestrator.Desktop/ViewModels/SettingsViewModel.cs @@ -6,6 +6,7 @@ using CommunityToolkit.Mvvm.Input; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using OTSSignsOrchestrator.Core.Configuration; +using OTSSignsOrchestrator.Core.Models.DTOs; using OTSSignsOrchestrator.Core.Services; namespace OTSSignsOrchestrator.Desktop.ViewModels; @@ -55,7 +56,7 @@ public partial class SettingsViewModel : ObservableObject [ObservableProperty] private string _defaultNewtImage = "fosrl/newt"; [ObservableProperty] private string _defaultMemcachedImage = "memcached:alpine"; [ObservableProperty] private string _defaultQuickChartImage = "ianw/quickchart"; - [ObservableProperty] private string _defaultCmsServerNameTemplate = "{abbrev}.ots-signs.com"; + [ObservableProperty] private string _defaultCmsServerNameTemplate = "app.ots-signs.com"; [ObservableProperty] private string _defaultThemeHostPath = "/cms/{abbrev}-cms-theme-custom"; [ObservableProperty] private string _defaultMySqlDbTemplate = "{abbrev}_cms_db"; [ObservableProperty] private string _defaultMySqlUserTemplate = "{abbrev}_cms_user"; @@ -71,6 +72,24 @@ public partial class SettingsViewModel : ObservableObject [ObservableProperty] private string _bitwardenProjectId = string.Empty; [ObservableProperty] private string _bitwardenInstanceProjectId = string.Empty; + // ── Authentik (SAML IdP) ──────────────────────────────────────── + [ObservableProperty] private string _authentikUrl = string.Empty; + [ObservableProperty] private string _authentikApiKey = string.Empty; + [ObservableProperty] private string _authentikAuthorizationFlowSlug = string.Empty; + [ObservableProperty] private string _authentikInvalidationFlowSlug = string.Empty; + [ObservableProperty] private string _authentikSigningKeypairId = string.Empty; + [ObservableProperty] private string _authentikStatusMessage = string.Empty; + [ObservableProperty] private bool _isAuthentikBusy; + + // Dropdown collections for Authentik flows / keypairs + public ObservableCollection AuthentikAuthorizationFlows { get; } = new(); + public ObservableCollection AuthentikInvalidationFlows { get; } = new(); + public ObservableCollection AuthentikKeypairs { get; } = new(); + + [ObservableProperty] private AuthentikFlowItem? _selectedAuthorizationFlow; + [ObservableProperty] private AuthentikFlowItem? _selectedInvalidationFlow; + [ObservableProperty] private AuthentikKeypairItem? _selectedSigningKeypair; + // ── Xibo Bootstrap OAuth2 ───────────────────────────────────── [ObservableProperty] private string _xiboBootstrapClientId = string.Empty; [ObservableProperty] private string _xiboBootstrapClientSecret = string.Empty; @@ -85,22 +104,27 @@ public partial class SettingsViewModel : ObservableObject [ObservableProperty] private bool _isBitwardenConfigured; [RelayCommand] - private async Task LoadAsync() + private Task LoadAsync() => LoadCoreAsync(skipBitwarden: false); + + private async Task LoadCoreAsync(bool skipBitwarden) { IsBusy = true; try { - // ── Load Bitwarden bootstrap config from IOptions ── - var bwOptions = _services.GetRequiredService>().Value; - BitwardenIdentityUrl = bwOptions.IdentityUrl; - BitwardenApiUrl = bwOptions.ApiUrl; - BitwardenAccessToken = bwOptions.AccessToken; - BitwardenOrganizationId = bwOptions.OrganizationId; - BitwardenProjectId = bwOptions.ProjectId; - BitwardenInstanceProjectId = bwOptions.InstanceProjectId; + if (!skipBitwarden) + { + // ── Load Bitwarden bootstrap config (IOptionsMonitor picks up appsettings.json changes) ── + var bwOptions = _services.GetRequiredService>().CurrentValue; + BitwardenIdentityUrl = bwOptions.IdentityUrl; + BitwardenApiUrl = bwOptions.ApiUrl; + BitwardenAccessToken = bwOptions.AccessToken; + BitwardenOrganizationId = bwOptions.OrganizationId; + BitwardenProjectId = bwOptions.ProjectId; + BitwardenInstanceProjectId = bwOptions.InstanceProjectId; + } - IsBitwardenConfigured = !string.IsNullOrWhiteSpace(bwOptions.AccessToken) - && !string.IsNullOrWhiteSpace(bwOptions.OrganizationId); + IsBitwardenConfigured = !string.IsNullOrWhiteSpace(BitwardenAccessToken) + && !string.IsNullOrWhiteSpace(BitwardenOrganizationId); if (!IsBitwardenConfigured) { @@ -146,7 +170,7 @@ public partial class SettingsViewModel : ObservableObject DefaultNewtImage = await svc.GetAsync(SettingsService.DefaultNewtImage, "fosrl/newt"); DefaultMemcachedImage = await svc.GetAsync(SettingsService.DefaultMemcachedImage, "memcached:alpine"); DefaultQuickChartImage = await svc.GetAsync(SettingsService.DefaultQuickChartImage, "ianw/quickchart"); - DefaultCmsServerNameTemplate = await svc.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "{abbrev}.ots-signs.com"); + DefaultCmsServerNameTemplate = await svc.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "app.ots-signs.com"); DefaultThemeHostPath = await svc.GetAsync(SettingsService.DefaultThemeHostPath, "/cms/{abbrev}-cms-theme-custom"); DefaultMySqlDbTemplate = await svc.GetAsync(SettingsService.DefaultMySqlDbTemplate, "{abbrev}_cms_db"); DefaultMySqlUserTemplate = await svc.GetAsync(SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms_user"); @@ -154,6 +178,17 @@ public partial class SettingsViewModel : ObservableObject DefaultPhpUploadMaxFilesize = await svc.GetAsync(SettingsService.DefaultPhpUploadMaxFilesize, "10G"); DefaultPhpMaxExecutionTime = await svc.GetAsync(SettingsService.DefaultPhpMaxExecutionTime, "600"); + // Authentik + AuthentikUrl = await svc.GetAsync(SettingsService.AuthentikUrl, string.Empty); + AuthentikApiKey = await svc.GetAsync(SettingsService.AuthentikApiKey, string.Empty); + AuthentikAuthorizationFlowSlug = await svc.GetAsync(SettingsService.AuthentikAuthorizationFlowSlug, string.Empty); + AuthentikInvalidationFlowSlug = await svc.GetAsync(SettingsService.AuthentikInvalidationFlowSlug, string.Empty); + AuthentikSigningKeypairId = await svc.GetAsync(SettingsService.AuthentikSigningKeypairId, string.Empty); + + // If Authentik URL + key are configured, try loading dropdowns + if (!string.IsNullOrWhiteSpace(AuthentikUrl) && !string.IsNullOrWhiteSpace(AuthentikApiKey)) + await FetchAuthentikDropdownsInternalAsync(); + // Xibo Bootstrap XiboBootstrapClientId = await svc.GetAsync(SettingsService.XiboBootstrapClientId, string.Empty); XiboBootstrapClientSecret = await svc.GetAsync(SettingsService.XiboBootstrapClientSecret, string.Empty); @@ -171,28 +206,51 @@ public partial class SettingsViewModel : ObservableObject } [RelayCommand] - private async Task SaveAsync() + private async Task SaveBitwardenLocalAsync() { IsBusy = true; try { - // ── 1. Save Bitwarden bootstrap config to appsettings.json ── await SaveBitwardenConfigToFileAsync(); - // Check if Bitwarden is now configured IsBitwardenConfigured = !string.IsNullOrWhiteSpace(BitwardenAccessToken) && !string.IsNullOrWhiteSpace(BitwardenOrganizationId); + StatusMessage = IsBitwardenConfigured + ? "Bitwarden config saved to appsettings.json." + : "Bitwarden config saved. Fill in Access Token and Org ID to enable all settings."; + } + catch (Exception ex) + { + StatusMessage = $"Error saving Bitwarden config: {ex.Message}"; + } + finally + { + IsBusy = false; + } + } + + [RelayCommand] + private async Task PullFromBitwardenAsync() + { + await LoadCoreAsync(skipBitwarden: true); + } + + [RelayCommand] + private async Task PushToBitwardenAsync() + { + IsBusy = true; + try + { if (!IsBitwardenConfigured) { - StatusMessage = "Bitwarden config saved to appsettings.json. Fill in Access Token and Org ID to enable all settings."; + StatusMessage = "Bitwarden is not configured. Save Bitwarden config first."; return; } - // ── 2. Save all other settings to Bitwarden ── using var scope = _services.CreateScope(); var svc = scope.ServiceProvider.GetRequiredService(); - svc.InvalidateCache(); // force re-read after config change + svc.InvalidateCache(); var settings = new List<(string Key, string? Value, string Category, bool IsSensitive)> { @@ -238,13 +296,20 @@ public partial class SettingsViewModel : ObservableObject (SettingsService.DefaultPhpUploadMaxFilesize, DefaultPhpUploadMaxFilesize, SettingsService.CatDefaults, false), (SettingsService.DefaultPhpMaxExecutionTime, DefaultPhpMaxExecutionTime, SettingsService.CatDefaults, false), + // Authentik + (SettingsService.AuthentikUrl, NullIfEmpty(AuthentikUrl), SettingsService.CatAuthentik, false), + (SettingsService.AuthentikApiKey, NullIfEmpty(AuthentikApiKey), SettingsService.CatAuthentik, true), + (SettingsService.AuthentikAuthorizationFlowSlug, NullIfEmpty(SelectedAuthorizationFlow?.Slug ?? AuthentikAuthorizationFlowSlug), SettingsService.CatAuthentik, false), + (SettingsService.AuthentikInvalidationFlowSlug, NullIfEmpty(SelectedInvalidationFlow?.Slug ?? AuthentikInvalidationFlowSlug), SettingsService.CatAuthentik, false), + (SettingsService.AuthentikSigningKeypairId, NullIfEmpty(SelectedSigningKeypair?.Pk ?? AuthentikSigningKeypairId), SettingsService.CatAuthentik, false), + // Xibo Bootstrap (SettingsService.XiboBootstrapClientId, NullIfEmpty(XiboBootstrapClientId), SettingsService.CatXibo, false), (SettingsService.XiboBootstrapClientSecret, NullIfEmpty(XiboBootstrapClientSecret), SettingsService.CatXibo, true), }; await svc.SaveManyAsync(settings); - StatusMessage = "Settings saved to Bitwarden."; + StatusMessage = "Settings pushed to Bitwarden."; } catch (Exception ex) { @@ -289,6 +354,135 @@ public partial class SettingsViewModel : ObservableObject } } + // ───────────────────────────────────────────────────────────────────────── + // Authentik: save, test, fetch dropdowns + // ───────────────────────────────────────────────────────────────────────── + + [RelayCommand] + private async Task SaveAndTestAuthentikAsync() + { + if (string.IsNullOrWhiteSpace(AuthentikUrl) || string.IsNullOrWhiteSpace(AuthentikApiKey)) + { + AuthentikStatusMessage = "Authentik URL and API Token are required."; + return; + } + + IsAuthentikBusy = true; + AuthentikStatusMessage = "Saving Authentik settings and testing connection..."; + try + { + // Persist URL + API key first so subsequent calls work + using var scope = _services.CreateScope(); + var svc = scope.ServiceProvider.GetRequiredService(); + 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(); + 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(); + + var flows = await authentik.ListFlowsAsync(overrideUrl, overrideApiKey); + var keypairs = await authentik.ListKeypairsAsync(overrideUrl, overrideApiKey); + + // Populate authorization flows (designation = "authorization") + AuthentikAuthorizationFlows.Clear(); + foreach (var f in flows.Where(f => f.Designation == "authorization")) + AuthentikAuthorizationFlows.Add(f); + + // Populate invalidation flows (designation = "invalidation") + AuthentikInvalidationFlows.Clear(); + foreach (var f in flows.Where(f => f.Designation == "invalidation")) + AuthentikInvalidationFlows.Add(f); + + // Populate keypairs + AuthentikKeypairs.Clear(); + // Add a "None" option + AuthentikKeypairs.Add(new AuthentikKeypairItem { Pk = "", Name = "(none)" }); + foreach (var k in keypairs) + AuthentikKeypairs.Add(k); + + // Select items matching saved slugs + SelectedAuthorizationFlow = AuthentikAuthorizationFlows + .FirstOrDefault(f => string.Equals(f.Slug, AuthentikAuthorizationFlowSlug, StringComparison.OrdinalIgnoreCase)) + ?? AuthentikAuthorizationFlows.FirstOrDefault(f => f.Slug == "default-provider-authorization-implicit-consent") + ?? AuthentikAuthorizationFlows.FirstOrDefault(); + + SelectedInvalidationFlow = AuthentikInvalidationFlows + .FirstOrDefault(f => string.Equals(f.Slug, AuthentikInvalidationFlowSlug, StringComparison.OrdinalIgnoreCase)) + ?? AuthentikInvalidationFlows.FirstOrDefault(f => f.Slug == "default-provider-invalidation-flow") + ?? AuthentikInvalidationFlows.FirstOrDefault(); + + SelectedSigningKeypair = string.IsNullOrWhiteSpace(AuthentikSigningKeypairId) + ? AuthentikKeypairs.First() // "(none)" + : AuthentikKeypairs.FirstOrDefault(k => k.Pk == AuthentikSigningKeypairId) + ?? AuthentikKeypairs.First(); + + // Update slug fields to match selection + if (SelectedAuthorizationFlow != null) + AuthentikAuthorizationFlowSlug = SelectedAuthorizationFlow.Slug; + if (SelectedInvalidationFlow != null) + AuthentikInvalidationFlowSlug = SelectedInvalidationFlow.Slug; + if (SelectedSigningKeypair != null) + AuthentikSigningKeypairId = SelectedSigningKeypair.Pk; + } + [RelayCommand] private async Task TestXiboBootstrapAsync() { @@ -305,7 +499,7 @@ public partial class SettingsViewModel : ObservableObject using var scope = _services.CreateScope(); var xibo = scope.ServiceProvider.GetRequiredService(); var svc = scope.ServiceProvider.GetRequiredService(); - var cmsServerTemplate = await svc.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "{abbrev}.ots-signs.com"); + var cmsServerTemplate = await svc.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "app.ots-signs.com"); // Use a placeholder URL — user must configure a live instance for full test StatusMessage = "Xibo bootstrap credentials saved. Connect test requires a live instance URL — use 'Details' on an instance to verify."; } diff --git a/OTSSignsOrchestrator.Desktop/Views/SettingsView.axaml b/OTSSignsOrchestrator.Desktop/Views/SettingsView.axaml index 919db8f..b7fbd00 100644 --- a/OTSSignsOrchestrator.Desktop/Views/SettingsView.axaml +++ b/OTSSignsOrchestrator.Desktop/Views/SettingsView.axaml @@ -14,12 +14,14 @@ -