using System.Security.Cryptography.X509Certificates; using System.Text.Json; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; using OTSSignsOrchestrator.Server.Clients; using OTSSignsOrchestrator.Server.Data; using OTSSignsOrchestrator.Server.Data.Entities; using OTSSignsOrchestrator.Server.Hubs; using OTSSignsOrchestrator.Server.Workers; namespace OTSSignsOrchestrator.Server.Api; public static class CustomerPortalApi { private const int MinCertDaysRemaining = 30; public static void MapCustomerPortalEndpoints(this WebApplication app) { var portal = app.MapGroup("/api/portal/byoi") .RequireAuthorization("CustomerPortal"); portal.MapPost("/configure", HandleConfigureByoi); portal.MapGet("/sp-metadata", HandleGetSpMetadata); portal.MapPost("/rotate-cert", HandleRotateCert); } // ── POST /api/portal/byoi/configure ───────────────────────────────────── private static async Task HandleConfigureByoi( ConfigureByoiRequest req, OrchestratorDbContext db, IHubContext hub, HttpContext httpContext, ILogger logger) { // Resolve customer from the authenticated JWT var customer = await ResolveCustomerAsync(httpContext, db); if (customer is null) return Results.Forbid(); if (customer.Plan != CustomerPlan.Pro) return Results.Problem("BYOI SAML is only available for Pro tier customers.", statusCode: 403); // Validate cert PEM var certValidation = ValidateCertPem(req.CertPem); if (certValidation is not null) return Results.ValidationProblem( new Dictionary { ["certPem"] = [certValidation] }); // Validate required fields var errors = new Dictionary(); if (string.IsNullOrWhiteSpace(req.SsoUrl)) errors["ssoUrl"] = ["ssoUrl is required."]; if (string.IsNullOrWhiteSpace(req.IdpEntityId)) errors["idpEntityId"] = ["idpEntityId is required."]; if (errors.Count > 0) return Results.ValidationProblem(errors); // Create a provision-byoi Job var parametersJson = JsonSerializer.Serialize(new ByoiParameters { CustomerCertPem = req.CertPem!, CustomerSsoUrl = req.SsoUrl!, CustomerIdpEntityId = req.IdpEntityId!, CustomerSloUrl = req.SloUrl, }); var job = new Job { Id = Guid.NewGuid(), CustomerId = customer.Id, JobType = "provision-byoi", Status = JobStatus.Queued, TriggeredBy = $"customer-portal:{customer.AdminEmail}", Parameters = parametersJson, CreatedAt = DateTime.UtcNow, }; db.Jobs.Add(job); await db.SaveChangesAsync(); logger.LogInformation("BYOI configure job {JobId} created for customer {CustomerId}", job.Id, customer.Id); await hub.Clients.All.SendJobCreated( job.Id.ToString(), customer.Abbreviation, job.JobType); return Results.Created($"/api/jobs/{job.Id}", new { jobId = job.Id }); } // ── GET /api/portal/byoi/sp-metadata ──────────────────────────────────── private static async Task HandleGetSpMetadata( OrchestratorDbContext db, IAuthentikClient authentikClient, HttpContext httpContext, ILogger logger) { var customer = await ResolveCustomerAsync(httpContext, db); if (customer is null) return Results.Forbid(); var instance = customer.Instances.FirstOrDefault(); if (instance is null) return Results.NotFound("No instance found for this customer."); var byoiConfig = await db.ByoiConfigs .AsNoTracking() .FirstOrDefaultAsync(b => b.InstanceId == instance.Id && b.Enabled); if (byoiConfig is null) return Results.NotFound("No BYOI configuration found for this instance."); var metadataResponse = await authentikClient.GetSamlSourceMetadataAsync(byoiConfig.Slug); if (!metadataResponse.IsSuccessStatusCode || metadataResponse.Content is null) { logger.LogError("Failed to fetch SP metadata for slug {Slug}: {Error}", byoiConfig.Slug, metadataResponse.Error?.Content ?? metadataResponse.ReasonPhrase); return Results.Problem("Failed to retrieve SP metadata from Authentik.", statusCode: 502); } return Results.Content(metadataResponse.Content, "application/xml"); } // ── POST /api/portal/byoi/rotate-cert ─────────────────────────────────── private static async Task HandleRotateCert( RotateCertRequest req, OrchestratorDbContext db, IHubContext hub, HttpContext httpContext, ILogger logger) { var customer = await ResolveCustomerAsync(httpContext, db); if (customer is null) return Results.Forbid(); if (customer.Plan != CustomerPlan.Pro) return Results.Problem("BYOI SAML is only available for Pro tier customers.", statusCode: 403); // Validate cert PEM var certValidation = ValidateCertPem(req.CertPem); if (certValidation is not null) return Results.ValidationProblem( new Dictionary { ["certPem"] = [certValidation] }); var instance = customer.Instances.FirstOrDefault(); if (instance is null) return Results.NotFound("No instance found for this customer."); var existingConfig = await db.ByoiConfigs .FirstOrDefaultAsync(b => b.InstanceId == instance.Id && b.Enabled); if (existingConfig is null) return Results.NotFound("No active BYOI configuration found to rotate."); // Create a re-provisioning job with the new cert var parametersJson = JsonSerializer.Serialize(new ByoiParameters { CustomerCertPem = req.CertPem!, CustomerSsoUrl = existingConfig.SsoUrl, CustomerIdpEntityId = existingConfig.EntityId, CustomerSloUrl = null, }); var job = new Job { Id = Guid.NewGuid(), CustomerId = customer.Id, JobType = "provision-byoi", Status = JobStatus.Queued, TriggeredBy = $"customer-portal:cert-rotate:{customer.AdminEmail}", Parameters = parametersJson, CreatedAt = DateTime.UtcNow, }; db.Jobs.Add(job); await db.SaveChangesAsync(); logger.LogInformation("BYOI cert rotate job {JobId} created for customer {CustomerId}", job.Id, customer.Id); await hub.Clients.All.SendJobCreated( job.Id.ToString(), customer.Abbreviation, job.JobType); return Results.Ok(new { jobId = job.Id }); } // ── Helpers ───────────────────────────────────────────────────────────── /// /// Validates a PEM certificate string. Returns an error message on failure, or null if valid. /// Rejects self-signed, expired, and certs expiring in < 30 days. /// private static string? ValidateCertPem(string? certPem) { if (string.IsNullOrWhiteSpace(certPem)) return "certPem is required."; X509Certificate2 cert; try { var base64 = certPem .Replace("-----BEGIN CERTIFICATE-----", "") .Replace("-----END CERTIFICATE-----", "") .Replace("\r", "") .Replace("\n", "") .Trim(); cert = X509CertificateLoader.LoadCertificate(Convert.FromBase64String(base64)); } catch (Exception) { return "Invalid certificate PEM format."; } using (cert) { if (cert.NotAfter.ToUniversalTime() < DateTime.UtcNow) return "Certificate has already expired."; if ((cert.NotAfter.ToUniversalTime() - DateTime.UtcNow).TotalDays < MinCertDaysRemaining) return $"Certificate expires in less than {MinCertDaysRemaining} days. Provide a certificate with a longer validity period."; // Reject self-signed: issuer == subject if (string.Equals(cert.Issuer, cert.Subject, StringComparison.OrdinalIgnoreCase)) return "Self-signed certificates are not accepted. Provide a CA-signed certificate."; } return null; } /// /// Resolves the current customer from the authenticated JWT claims. /// Expects a "customer_id" claim in the token. /// private static async Task ResolveCustomerAsync(HttpContext httpContext, OrchestratorDbContext db) { var customerIdClaim = httpContext.User.FindFirst("customer_id")?.Value; if (customerIdClaim is null || !Guid.TryParse(customerIdClaim, out var customerId)) return null; return await db.Customers .Include(c => c.Instances) .FirstOrDefaultAsync(c => c.Id == customerId); } } // ── Request DTOs ──────────────────────────────────────────────────────────── public record ConfigureByoiRequest(string? CertPem, string? SsoUrl, string? IdpEntityId, string? SloUrl); public record RotateCertRequest(string? CertPem);