using System.Security.Cryptography.X509Certificates; using System.Text.Json; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using Quartz; using OTSSignsOrchestrator.Server.Clients; using OTSSignsOrchestrator.Server.Data; using OTSSignsOrchestrator.Server.Data.Entities; using OTSSignsOrchestrator.Server.Hubs; using OTSSignsOrchestrator.Server.Jobs; namespace OTSSignsOrchestrator.Server.Workers; /// /// BYOI SAML provisioning pipeline — configures an upstream SAML source in Authentik /// so a Pro-tier customer can federate their own Identity Provider. /// /// Handles JobType = "provision-byoi". /// /// Steps: /// 1. import-cert — Import the customer's public cert PEM into Authentik /// 2. create-saml-source — Create the upstream SAML source in Authentik /// 3. store-metadata — Persist slug, entity_id, sso_url, cert info to ByoiConfig /// 4. schedule-cert-monitor — Register a Quartz ByoiCertExpiryJob for daily cert expiry checks /// /// IMPORTANT: Xibo's settings-custom.php is IDENTICAL for Pro BYOI and Essentials. /// Xibo always authenticates through Authentik. BYOI is entirely handled within /// Authentik's upstream SAML Source config — no Xibo changes are needed. /// public sealed class ByoiSamlPipeline : IProvisioningPipeline { public string HandlesJobType => "provision-byoi"; private const int TotalSteps = 4; private readonly IServiceProvider _services; private readonly ILogger _logger; public ByoiSamlPipeline( IServiceProvider services, ILogger logger) { _services = services; _logger = logger; } public async Task ExecuteAsync(Job job, CancellationToken ct) { await using var scope = _services.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService(); var hub = scope.ServiceProvider.GetRequiredService>(); var authentikClient = scope.ServiceProvider.GetRequiredService(); var authentikOpts = scope.ServiceProvider.GetRequiredService>().Value; var schedulerFactory = scope.ServiceProvider.GetRequiredService(); // Load customer + instance var customer = await db.Customers .Include(c => c.Instances) .FirstOrDefaultAsync(c => c.Id == job.CustomerId, ct) ?? throw new InvalidOperationException($"Customer {job.CustomerId} not found."); if (customer.Plan != CustomerPlan.Pro) throw new InvalidOperationException("BYOI SAML is only available for Pro tier customers."); var instance = customer.Instances.FirstOrDefault() ?? throw new InvalidOperationException($"No instance found for customer {job.CustomerId}."); var abbrev = customer.Abbreviation; var companyName = customer.CompanyName; // Parse BYOI parameters from Job.Parameters JSON var parms = JsonSerializer.Deserialize( job.Parameters ?? throw new InvalidOperationException("Job.Parameters is null."), new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? throw new InvalidOperationException("Failed to deserialize BYOI parameters."); var runner = new StepRunner(db, hub, _logger, job.Id, TotalSteps); // Mutable state accumulated across steps string? verificationKpId = null; string slug = $"byoi-{abbrev}"; DateTime certExpiry; // Parse the cert up front to get expiry and validate using var cert = X509CertificateLoader.LoadCertificate( Convert.FromBase64String(ExtractBase64FromPem(parms.CustomerCertPem))); certExpiry = cert.NotAfter.ToUniversalTime(); // ── Step 1: import-cert ───────────────────────────────────────────── await runner.RunAsync("import-cert", async () => { var response = await authentikClient.ImportCertificateAsync(new ImportCertRequest( Name: $"byoi-{abbrev}-cert", CertificateData: parms.CustomerCertPem, KeyData: null)); // PUBLIC cert only — no private key if (!response.IsSuccessStatusCode || response.Content is null) throw new InvalidOperationException( $"Failed to import certificate: {response.Error?.Content ?? response.ReasonPhrase}"); verificationKpId = response.Content["pk"]?.ToString() ?? throw new InvalidOperationException("Authentik cert import returned no pk."); return $"Imported customer cert as keypair '{verificationKpId}'."; }, ct); // ── Step 2: create-saml-source ────────────────────────────────────── await runner.RunAsync("create-saml-source", async () => { var response = await authentikClient.CreateSamlSourceAsync(new CreateSamlSourceRequest( Name: $"{companyName} IdP", Slug: slug, SsoUrl: parms.CustomerSsoUrl, SloUrl: parms.CustomerSloUrl, Issuer: parms.CustomerIdpEntityId, SigningKp: string.IsNullOrWhiteSpace(authentikOpts.OtsSigningKpId) ? null : authentikOpts.OtsSigningKpId, VerificationKp: verificationKpId, BindingType: "redirect", NameIdPolicy: "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", PreAuthenticationFlow: authentikOpts.SourcePreAuthFlowSlug, AuthenticationFlow: authentikOpts.SourceAuthFlowSlug, AllowIdpInitiated: false)); // REQUIRED: IdP-initiated SSO is a CSRF risk if (!response.IsSuccessStatusCode || response.Content is null) throw new InvalidOperationException( $"Failed to create SAML source: {response.Error?.Content ?? response.ReasonPhrase}"); return $"SAML source '{slug}' created with AllowIdpInitiated=false."; }, ct); // ── Step 3: store-metadata ────────────────────────────────────────── await runner.RunAsync("store-metadata", async () => { var byoiConfig = new ByoiConfig { Id = Guid.NewGuid(), InstanceId = instance.Id, Slug = slug, EntityId = parms.CustomerIdpEntityId, SsoUrl = parms.CustomerSsoUrl, CertPem = parms.CustomerCertPem, CertExpiry = certExpiry, Enabled = true, CreatedAt = DateTime.UtcNow, }; db.ByoiConfigs.Add(byoiConfig); await db.SaveChangesAsync(ct); return $"ByoiConfig stored: slug={slug}, certExpiry={certExpiry:O}."; }, ct); // ── Step 4: schedule-cert-monitor ─────────────────────────────────── await runner.RunAsync("schedule-cert-monitor", async () => { var scheduler = await schedulerFactory.GetScheduler(ct); var jobKey = new JobKey($"byoi-cert-expiry-{abbrev}", "byoi-cert-expiry"); // Only schedule if not already present (idempotent) if (!await scheduler.CheckExists(jobKey, ct)) { var quartzJob = JobBuilder.Create() .WithIdentity(jobKey) .UsingJobData("instanceId", instance.Id.ToString()) .Build(); var trigger = TriggerBuilder.Create() .WithIdentity($"byoi-cert-expiry-{abbrev}-trigger", "byoi-cert-expiry") .WithDailyTimeIntervalSchedule(s => s .OnEveryDay() .StartingDailyAt(TimeOfDay.HourAndMinuteOfDay(6, 0)) .WithRepeatCount(1)) .StartNow() .Build(); await scheduler.ScheduleJob(quartzJob, trigger, ct); } return $"Quartz ByoiCertExpiryJob scheduled daily for instance {instance.Id}."; }, ct); } /// /// Extracts the Base64 payload from a PEM string, stripping headers/footers. /// private static string ExtractBase64FromPem(string pem) { return pem .Replace("-----BEGIN CERTIFICATE-----", "") .Replace("-----END CERTIFICATE-----", "") .Replace("\r", "") .Replace("\n", "") .Trim(); } } /// /// Deserialized from Job.Parameters JSON for provision-byoi jobs. /// public sealed record ByoiParameters { public string CustomerCertPem { get; init; } = string.Empty; public string CustomerSsoUrl { get; init; } = string.Empty; public string CustomerIdpEntityId { get; init; } = string.Empty; public string? CustomerSloUrl { get; init; } }