feat: Add Instance Details ViewModel and UI for managing instance credentials
- Introduced InstanceDetailsViewModel to handle loading and displaying instance-specific credentials. - Created InstanceDetailsWindow and associated XAML for displaying admin, database, and OAuth2 credentials. - Updated InstancesViewModel to include command for opening instance details. - Enhanced SettingsViewModel to manage Bitwarden and Xibo Bootstrap configurations, including connection testing. - Added UI components for Bitwarden Secrets Manager and Xibo Bootstrap OAuth2 settings in the SettingsView. - Implemented password visibility toggles and clipboard copy functionality for sensitive information.
This commit is contained in:
255
OTSSignsOrchestrator.Core/Services/BitwardenSecretService.cs
Normal file
255
OTSSignsOrchestrator.Core/Services/BitwardenSecretService.cs
Normal file
@@ -0,0 +1,255 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace OTSSignsOrchestrator.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Stores and retrieves secrets from Bitwarden Secrets Manager (machine account API).
|
||||
///
|
||||
/// Configuration required in Settings:
|
||||
/// Bitwarden.IdentityUrl – defaults to https://identity.bitwarden.com
|
||||
/// Bitwarden.ApiUrl – defaults to https://api.bitwarden.com
|
||||
/// Bitwarden.AccessToken – machine account access token (sensitive)
|
||||
/// Bitwarden.OrganizationId – Bitwarden organisation that owns the project
|
||||
/// Bitwarden.ProjectId – project where new secrets are created
|
||||
/// </summary>
|
||||
public class BitwardenSecretService : IBitwardenSecretService
|
||||
{
|
||||
private readonly IHttpClientFactory _http;
|
||||
private readonly SettingsService _settings;
|
||||
private readonly ILogger<BitwardenSecretService> _logger;
|
||||
|
||||
// Cached bearer token (refreshed per service lifetime — transient registration is assumed)
|
||||
private string? _bearerToken;
|
||||
|
||||
public BitwardenSecretService(
|
||||
IHttpClientFactory http,
|
||||
SettingsService settings,
|
||||
ILogger<BitwardenSecretService> logger)
|
||||
{
|
||||
_http = http;
|
||||
_settings = settings;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// IBitwardenSecretService
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public async Task<bool> IsConfiguredAsync()
|
||||
{
|
||||
var token = await _settings.GetAsync(SettingsService.BitwardenAccessToken);
|
||||
var orgId = await _settings.GetAsync(SettingsService.BitwardenOrganizationId);
|
||||
return !string.IsNullOrWhiteSpace(token) && !string.IsNullOrWhiteSpace(orgId);
|
||||
}
|
||||
|
||||
public async Task<string> CreateSecretAsync(string key, string value, string note = "")
|
||||
{
|
||||
var bearer = await GetBearerTokenAsync();
|
||||
var projectId = await _settings.GetAsync(SettingsService.BitwardenProjectId);
|
||||
var orgId = await _settings.GetAsync(SettingsService.BitwardenOrganizationId)
|
||||
?? throw new InvalidOperationException("Bitwarden OrganizationId is not configured.");
|
||||
var apiUrl = await GetApiUrlAsync();
|
||||
|
||||
var client = CreateClient(bearer);
|
||||
var body = new
|
||||
{
|
||||
organizationId = orgId,
|
||||
projectIds = projectId is not null ? new[] { projectId } : Array.Empty<string>(),
|
||||
key = key,
|
||||
value = value,
|
||||
note = note
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync($"{apiUrl}/secrets", body);
|
||||
await EnsureSuccessAsync(response, "create secret");
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<BwsSecretResponse>()
|
||||
?? throw new InvalidOperationException("Empty response from Bitwarden API.");
|
||||
|
||||
_logger.LogInformation("Bitwarden secret created: key={Key}, id={Id}", key, result.Id);
|
||||
return result.Id;
|
||||
}
|
||||
|
||||
public async Task<BitwardenSecret> GetSecretAsync(string secretId)
|
||||
{
|
||||
var bearer = await GetBearerTokenAsync();
|
||||
var apiUrl = await GetApiUrlAsync();
|
||||
var client = CreateClient(bearer);
|
||||
|
||||
var response = await client.GetAsync($"{apiUrl}/secrets/{secretId}");
|
||||
await EnsureSuccessAsync(response, "get secret");
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<BwsSecretResponse>()
|
||||
?? throw new InvalidOperationException("Empty response from Bitwarden API.");
|
||||
|
||||
return new BitwardenSecret
|
||||
{
|
||||
Id = result.Id,
|
||||
Key = result.Key,
|
||||
Value = result.Value,
|
||||
Note = result.Note ?? string.Empty,
|
||||
CreationDate = result.CreationDate
|
||||
};
|
||||
}
|
||||
|
||||
public async Task UpdateSecretAsync(string secretId, string key, string value, string note = "")
|
||||
{
|
||||
var bearer = await GetBearerTokenAsync();
|
||||
var projectId = await _settings.GetAsync(SettingsService.BitwardenProjectId);
|
||||
var orgId = await _settings.GetAsync(SettingsService.BitwardenOrganizationId)
|
||||
?? throw new InvalidOperationException("Bitwarden OrganizationId is not configured.");
|
||||
var apiUrl = await GetApiUrlAsync();
|
||||
|
||||
var client = CreateClient(bearer);
|
||||
var body = new
|
||||
{
|
||||
organizationId = orgId,
|
||||
projectIds = projectId is not null ? new[] { projectId } : Array.Empty<string>(),
|
||||
key = key,
|
||||
value = value,
|
||||
note = note
|
||||
};
|
||||
|
||||
var response = await client.PutAsJsonAsync($"{apiUrl}/secrets/{secretId}", body);
|
||||
await EnsureSuccessAsync(response, "update secret");
|
||||
|
||||
_logger.LogInformation("Bitwarden secret updated: key={Key}, id={Id}", key, secretId);
|
||||
}
|
||||
|
||||
public async Task<List<BitwardenSecretSummary>> ListSecretsAsync()
|
||||
{
|
||||
var bearer = await GetBearerTokenAsync();
|
||||
var orgId = await _settings.GetAsync(SettingsService.BitwardenOrganizationId)
|
||||
?? throw new InvalidOperationException("Bitwarden OrganizationId is not configured.");
|
||||
var apiUrl = await GetApiUrlAsync();
|
||||
var client = CreateClient(bearer);
|
||||
|
||||
var response = await client.GetAsync($"{apiUrl}/organizations/{orgId}/secrets");
|
||||
await EnsureSuccessAsync(response, "list secrets");
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<BwsSecretsListResponse>();
|
||||
|
||||
return result?.Data?.Select(s => new BitwardenSecretSummary
|
||||
{
|
||||
Id = s.Id,
|
||||
Key = s.Key,
|
||||
CreationDate = s.CreationDate
|
||||
}).ToList() ?? new List<BitwardenSecretSummary>();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Auth
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Exchanges the machine-account access token for a short-lived Bearer token.
|
||||
/// The access token format is: 0.{tokenId}.{clientSecret}:{encKeyB64}
|
||||
/// The client_id used is "machine.{tokenId}".
|
||||
/// </summary>
|
||||
private async Task<string> GetBearerTokenAsync()
|
||||
{
|
||||
if (_bearerToken is not null)
|
||||
return _bearerToken;
|
||||
|
||||
var rawToken = await _settings.GetAsync(SettingsService.BitwardenAccessToken)
|
||||
?? throw new InvalidOperationException("Bitwarden AccessToken is not configured in Settings.");
|
||||
var identityUrl = await GetIdentityUrlAsync();
|
||||
|
||||
// Parse token: "0.<tokenId>.<clientSecretAndKey>" — split off the first two segments
|
||||
var parts = rawToken.Split('.', 3);
|
||||
if (parts.Length < 3)
|
||||
throw new FormatException(
|
||||
"Bitwarden access token has unexpected format. Expected: 0.<tokenId>.<rest>");
|
||||
|
||||
var tokenId = parts[1];
|
||||
var clientSecret = parts[2]; // may contain ":base64key" suffix — include all of it
|
||||
|
||||
var client = _http.CreateClient("Bitwarden");
|
||||
var form = new FormUrlEncodedContent(new[]
|
||||
{
|
||||
new KeyValuePair<string, string>("grant_type", "client_credentials"),
|
||||
new KeyValuePair<string, string>("client_id", $"machine.{tokenId}"),
|
||||
new KeyValuePair<string, string>("client_secret", clientSecret),
|
||||
new KeyValuePair<string, string>("scope", "api.secrets"),
|
||||
});
|
||||
|
||||
var response = await client.PostAsync($"{identityUrl}/connect/token", form);
|
||||
await EnsureSuccessAsync(response, "authenticate with Bitwarden identity");
|
||||
|
||||
var token = await response.Content.ReadFromJsonAsync<BwsTokenResponse>()
|
||||
?? throw new InvalidOperationException("Empty token response from Bitwarden.");
|
||||
|
||||
_bearerToken = token.AccessToken;
|
||||
_logger.LogInformation("Bitwarden bearer token acquired.");
|
||||
return _bearerToken;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private async Task<string> GetIdentityUrlAsync()
|
||||
=> (await _settings.GetAsync(SettingsService.BitwardenIdentityUrl, "https://identity.bitwarden.com")).TrimEnd('/');
|
||||
|
||||
private async Task<string> GetApiUrlAsync()
|
||||
=> (await _settings.GetAsync(SettingsService.BitwardenApiUrl, "https://api.bitwarden.com")).TrimEnd('/');
|
||||
|
||||
private static HttpClient CreateClient(string bearerToken)
|
||||
{
|
||||
var client = new HttpClient();
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", bearerToken);
|
||||
return client;
|
||||
}
|
||||
|
||||
private static async Task EnsureSuccessAsync(HttpResponseMessage response, string operation)
|
||||
{
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
throw new HttpRequestException(
|
||||
$"Bitwarden API call '{operation}' failed: {(int)response.StatusCode} {response.ReasonPhrase} — {body}");
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Internal DTOs
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private sealed class BwsTokenResponse
|
||||
{
|
||||
[JsonPropertyName("access_token")]
|
||||
public string AccessToken { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
private sealed class BwsSecretResponse
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("organizationId")]
|
||||
public string OrganizationId { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("key")]
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("value")]
|
||||
public string Value { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("note")]
|
||||
public string? Note { get; set; }
|
||||
|
||||
[JsonPropertyName("creationDate")]
|
||||
public DateTime CreationDate { get; set; }
|
||||
}
|
||||
|
||||
private sealed class BwsSecretsListResponse
|
||||
{
|
||||
[JsonPropertyName("data")]
|
||||
public List<BwsSecretResponse>? Data { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
namespace OTSSignsOrchestrator.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction for storing and retrieving secrets via Bitwarden Secrets Manager.
|
||||
/// </summary>
|
||||
public interface IBitwardenSecretService
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns true if Bitwarden is configured (access token + org ID are set).
|
||||
/// </summary>
|
||||
Task<bool> IsConfiguredAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new secret in the configured Bitwarden project.
|
||||
/// </summary>
|
||||
/// <returns>The ID of the created secret.</returns>
|
||||
Task<string> CreateSecretAsync(string key, string value, string note = "");
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a secret by its Bitwarden ID.
|
||||
/// </summary>
|
||||
Task<BitwardenSecret> GetSecretAsync(string secretId);
|
||||
|
||||
/// <summary>
|
||||
/// Updates the value of an existing secret in place.
|
||||
/// </summary>
|
||||
Task UpdateSecretAsync(string secretId, string key, string value, string note = "");
|
||||
|
||||
/// <summary>
|
||||
/// Lists all secrets in the configured project.
|
||||
/// </summary>
|
||||
Task<List<BitwardenSecretSummary>> ListSecretsAsync();
|
||||
}
|
||||
|
||||
public class BitwardenSecret
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string Key { get; set; } = string.Empty;
|
||||
public string Value { get; set; } = string.Empty;
|
||||
public string Note { get; set; } = string.Empty;
|
||||
public DateTime CreationDate { get; set; }
|
||||
}
|
||||
|
||||
public class BitwardenSecretSummary
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string Key { get; set; } = string.Empty;
|
||||
public DateTime CreationDate { get; set; }
|
||||
}
|
||||
@@ -32,6 +32,7 @@ public class InstanceService
|
||||
private readonly IDockerSecretsService _secrets;
|
||||
private readonly XiboApiService _xibo;
|
||||
private readonly SettingsService _settings;
|
||||
private readonly PostInstanceInitService _postInit;
|
||||
private readonly DockerOptions _dockerOptions;
|
||||
private readonly ILogger<InstanceService> _logger;
|
||||
|
||||
@@ -44,6 +45,7 @@ public class InstanceService
|
||||
IDockerSecretsService secrets,
|
||||
XiboApiService xibo,
|
||||
SettingsService settings,
|
||||
PostInstanceInitService postInit,
|
||||
IOptions<DockerOptions> dockerOptions,
|
||||
ILogger<InstanceService> logger)
|
||||
{
|
||||
@@ -55,6 +57,7 @@ public class InstanceService
|
||||
_secrets = secrets;
|
||||
_xibo = xibo;
|
||||
_settings = settings;
|
||||
_postInit = postInit;
|
||||
_dockerOptions = dockerOptions.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
@@ -255,8 +258,13 @@ public class InstanceService
|
||||
|
||||
_logger.LogInformation("Instance created: {StackName} | duration={DurationMs}ms", stackName, sw.ElapsedMilliseconds);
|
||||
|
||||
// ── 8. Post-instance init (fire-and-forget background task) ──────
|
||||
// Waits for Xibo to be ready then creates admin user, OAuth app, and sets theme.
|
||||
var instanceUrl = $"https://{cmsServerName}";
|
||||
_ = Task.Run(async () => await _postInit.RunAsync(abbrev, instanceUrl));
|
||||
|
||||
deployResult.ServiceCount = 4;
|
||||
deployResult.Message = "Instance deployed successfully.";
|
||||
deployResult.Message = "Instance deployed successfully. Post-install setup is running in background.";
|
||||
return deployResult;
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
345
OTSSignsOrchestrator.Core/Services/PostInstanceInitService.cs
Normal file
345
OTSSignsOrchestrator.Core/Services/PostInstanceInitService.cs
Normal file
@@ -0,0 +1,345 @@
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OTSSignsOrchestrator.Core.Data;
|
||||
|
||||
namespace OTSSignsOrchestrator.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Runs once after a Xibo CMS stack is deployed to complete post-install setup:
|
||||
/// <list type="number">
|
||||
/// <item>Waits for the Xibo web service to become available.</item>
|
||||
/// <item>Creates the OTS admin user with a random password.</item>
|
||||
/// <item>Registers a dedicated client_credentials OAuth2 application for OTS.</item>
|
||||
/// <item>Activates the <c>otssigns</c> theme.</item>
|
||||
/// <item>Stores all generated credentials in Bitwarden Secrets Manager.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// Invoked as a background fire-and-forget task from <see cref="InstanceService.CreateInstanceAsync"/>.
|
||||
/// Progress is tracked in the operation log; failures are logged but do not roll back the deployment.
|
||||
/// </summary>
|
||||
public class PostInstanceInitService
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly ILogger<PostInstanceInitService> _logger;
|
||||
|
||||
// How long to wait for Xibo to become healthy before aborting post-init.
|
||||
private static readonly TimeSpan XiboReadinessTimeout = TimeSpan.FromMinutes(15);
|
||||
|
||||
public PostInstanceInitService(
|
||||
IServiceProvider services,
|
||||
ILogger<PostInstanceInitService> logger)
|
||||
{
|
||||
_services = services;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Entry point
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Executes the post-instance initialisation sequence.
|
||||
/// Intended to be called as a background task after stack deployment succeeds.
|
||||
/// </summary>
|
||||
public async Task RunAsync(string abbrev, string instanceUrl, CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogInformation("[PostInit] Starting post-instance init for {Abbrev} ({Url})", abbrev, instanceUrl);
|
||||
|
||||
try
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var xibo = scope.ServiceProvider.GetRequiredService<XiboApiService>();
|
||||
var bws = scope.ServiceProvider.GetRequiredService<IBitwardenSecretService>();
|
||||
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
||||
var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
|
||||
|
||||
// ── Validate Bitwarden is configured ────────────────────────────
|
||||
if (!await bws.IsConfiguredAsync())
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"[PostInit] Bitwarden is not configured — credentials will NOT be stored in Bitwarden. " +
|
||||
"Configure Settings → Bitwarden to enable secret storage.");
|
||||
}
|
||||
|
||||
// ── Read bootstrap credentials ───────────────────────────────────
|
||||
var bootstrapClientId = await settings.GetAsync(SettingsService.XiboBootstrapClientId);
|
||||
var bootstrapClientSecret = await settings.GetAsync(SettingsService.XiboBootstrapClientSecret);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(bootstrapClientId) || string.IsNullOrWhiteSpace(bootstrapClientSecret))
|
||||
throw new InvalidOperationException(
|
||||
"Xibo bootstrap OAuth2 credentials are not configured. " +
|
||||
"Set Settings → Xibo Bootstrap Client ID / Secret to enable post-instance setup.");
|
||||
|
||||
// ── 1. Wait for Xibo readiness ────────────────────────────────────
|
||||
_logger.LogInformation("[PostInit] Waiting for Xibo to become ready at {Url}...", instanceUrl);
|
||||
var ready = await xibo.WaitForReadyAsync(instanceUrl, XiboReadinessTimeout, ct);
|
||||
if (!ready)
|
||||
throw new TimeoutException(
|
||||
$"Xibo instance at {instanceUrl} did not become ready within {XiboReadinessTimeout.TotalMinutes} minutes.");
|
||||
|
||||
// ── 2. Generate credentials ───────────────────────────────────────
|
||||
var adminUsername = $"ots-admin-{abbrev}";
|
||||
var adminPassword = GeneratePassword(24);
|
||||
var adminEmail = $"ots-admin-{abbrev}@ots-signs.com";
|
||||
|
||||
// ── 3. Create OTS admin user ──────────────────────────────────────
|
||||
_logger.LogInformation("[PostInit] Creating OTS admin user '{Username}'", adminUsername);
|
||||
int userId = await xibo.CreateAdminUserAsync(
|
||||
instanceUrl, bootstrapClientId, bootstrapClientSecret,
|
||||
adminUsername, adminPassword, adminEmail);
|
||||
|
||||
// ── 4. Register dedicated OAuth2 application ──────────────────────
|
||||
_logger.LogInformation("[PostInit] Registering OTS OAuth2 application");
|
||||
var (oauthClientId, oauthClientSecret) = await xibo.RegisterOAuthClientAsync(
|
||||
instanceUrl, bootstrapClientId, bootstrapClientSecret,
|
||||
appName: $"OTS Signs — {abbrev.ToUpperInvariant()}");
|
||||
|
||||
// ── 5. Set theme ──────────────────────────────────────────────────
|
||||
_logger.LogInformation("[PostInit] Setting Xibo theme to 'otssigns'");
|
||||
await xibo.SetThemeAsync(instanceUrl, bootstrapClientId, bootstrapClientSecret, "otssigns");
|
||||
|
||||
// ── 6. Store credentials in Bitwarden ─────────────────────────────
|
||||
if (await bws.IsConfiguredAsync())
|
||||
{
|
||||
_logger.LogInformation("[PostInit] Storing credentials in Bitwarden");
|
||||
|
||||
var adminSecretId = await bws.CreateSecretAsync(
|
||||
key: $"{abbrev}/xibo-admin-password",
|
||||
value: adminPassword,
|
||||
note: $"Xibo CMS admin password for instance {abbrev}. Username: {adminUsername}");
|
||||
|
||||
var oauthSecretId = await bws.CreateSecretAsync(
|
||||
key: $"{abbrev}/xibo-oauth-secret",
|
||||
value: oauthClientSecret,
|
||||
note: $"Xibo CMS OAuth2 client secret for OTS application. ClientId: {oauthClientId}");
|
||||
|
||||
// Persist Bitwarden secret IDs + OAuth client ID in AppSettings for later retrieval
|
||||
await settings.SetAsync(SettingsService.InstanceAdminPasswordSecretId(abbrev), adminSecretId,
|
||||
SettingsService.CatInstance, isSensitive: false);
|
||||
await settings.SetAsync(SettingsService.InstanceOAuthSecretId(abbrev), oauthSecretId,
|
||||
SettingsService.CatInstance, isSensitive: false);
|
||||
await settings.SetAsync(SettingsService.InstanceOAuthClientId(abbrev), oauthClientId,
|
||||
SettingsService.CatInstance, isSensitive: false);
|
||||
}
|
||||
else
|
||||
{
|
||||
// No Bitwarden — fall back to encrypted AppSettings (less ideal for secrets)
|
||||
_logger.LogWarning(
|
||||
"[PostInit] Bitwarden not configured. Storing admin password locally (encrypted). " +
|
||||
"OAuth client secret is discarded — re-register from the instance details modal.");
|
||||
|
||||
await settings.SetAsync(
|
||||
$"Instance.{abbrev}.AdminPassword", adminPassword,
|
||||
SettingsService.CatInstance, isSensitive: true);
|
||||
await settings.SetAsync(SettingsService.InstanceOAuthClientId(abbrev), oauthClientId,
|
||||
SettingsService.CatInstance, isSensitive: false);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
_logger.LogInformation(
|
||||
"[PostInit] Post-instance init complete for {Abbrev}. Admin user: {Username}, OAuth ClientId: {ClientId}",
|
||||
abbrev, adminUsername, oauthClientId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[PostInit] Post-instance init failed for {Abbrev}: {Message}", abbrev, ex.Message);
|
||||
// Do NOT rethrow — the stack is already deployed; we don't want to break the deployment result.
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Credential rotation (called from the UI)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Generates a new OTS admin password, updates it in Xibo, and rotates the Bitwarden secret.
|
||||
/// </summary>
|
||||
public async Task<string> RotateAdminPasswordAsync(string abbrev, string instanceUrl)
|
||||
{
|
||||
_logger.LogInformation("[PostInit] Rotating admin password for {Abbrev}", abbrev);
|
||||
|
||||
using var scope = _services.CreateScope();
|
||||
var xibo = scope.ServiceProvider.GetRequiredService<XiboApiService>();
|
||||
var bws = scope.ServiceProvider.GetRequiredService<IBitwardenSecretService>();
|
||||
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
||||
var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
|
||||
|
||||
// Get bootstrap credentials to authenticate against Xibo API
|
||||
var bootstrapClientId = await settings.GetAsync(SettingsService.XiboBootstrapClientId)
|
||||
?? throw new InvalidOperationException("Xibo bootstrap client not configured.");
|
||||
var bootstrapClientSecret = await settings.GetAsync(SettingsService.XiboBootstrapClientSecret)
|
||||
?? throw new InvalidOperationException("Xibo bootstrap client secret not configured.");
|
||||
|
||||
// Xibo user ID: we store it as the numeric userId in the username convention
|
||||
// We need to look it up from the admin username. For now derive from OTS convention.
|
||||
var adminUsername = $"ots-admin-{abbrev}";
|
||||
var newPassword = GeneratePassword(24);
|
||||
|
||||
// We need to look up the userId — use the Xibo API (list users, find by userName)
|
||||
var userId = await GetXiboUserIdAsync(xibo, instanceUrl, bootstrapClientId, bootstrapClientSecret, adminUsername);
|
||||
|
||||
await xibo.RotateUserPasswordAsync(instanceUrl, bootstrapClientId, bootstrapClientSecret, userId, newPassword);
|
||||
|
||||
// Update Bitwarden secret if available
|
||||
if (await bws.IsConfiguredAsync())
|
||||
{
|
||||
var secretId = await settings.GetAsync(SettingsService.InstanceAdminPasswordSecretId(abbrev));
|
||||
if (!string.IsNullOrWhiteSpace(secretId))
|
||||
{
|
||||
await bws.UpdateSecretAsync(secretId,
|
||||
key: $"{abbrev}/xibo-admin-password",
|
||||
value: newPassword,
|
||||
note: $"Rotated on {DateTime.UtcNow:yyyy-MM-dd HH:mm} UTC. Username: {adminUsername}");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Secret doesn't exist yet in Bitwarden — create it now
|
||||
var newSecretId = await bws.CreateSecretAsync(
|
||||
$"{abbrev}/xibo-admin-password", newPassword,
|
||||
$"Rotated on {DateTime.UtcNow:yyyy-MM-dd HH:mm} UTC. Username: {adminUsername}");
|
||||
await settings.SetAsync(SettingsService.InstanceAdminPasswordSecretId(abbrev), newSecretId,
|
||||
SettingsService.CatInstance, isSensitive: false);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fallback: encrypted AppSettings
|
||||
await settings.SetAsync($"Instance.{abbrev}.AdminPassword", newPassword,
|
||||
SettingsService.CatInstance, isSensitive: true);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
_logger.LogInformation("[PostInit] Admin password rotated for {Abbrev}", abbrev);
|
||||
return newPassword;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the stored admin password for an instance (from Bitwarden or local AppSettings).
|
||||
/// </summary>
|
||||
public async Task<InstanceCredentials> GetCredentialsAsync(string abbrev)
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var bws = scope.ServiceProvider.GetRequiredService<IBitwardenSecretService>();
|
||||
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
||||
|
||||
var adminUsername = $"ots-admin-{abbrev}";
|
||||
var oauthClientId = await settings.GetAsync(SettingsService.InstanceOAuthClientId(abbrev));
|
||||
string? adminPassword = null;
|
||||
string? oauthClientSecret = null;
|
||||
|
||||
if (await bws.IsConfiguredAsync())
|
||||
{
|
||||
var adminSecretId = await settings.GetAsync(SettingsService.InstanceAdminPasswordSecretId(abbrev));
|
||||
if (!string.IsNullOrWhiteSpace(adminSecretId))
|
||||
{
|
||||
try
|
||||
{
|
||||
var secret = await bws.GetSecretAsync(adminSecretId);
|
||||
adminPassword = secret.Value;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Could not retrieve admin password from Bitwarden for {Abbrev}", abbrev);
|
||||
}
|
||||
}
|
||||
|
||||
var oauthSecretId = await settings.GetAsync(SettingsService.InstanceOAuthSecretId(abbrev));
|
||||
if (!string.IsNullOrWhiteSpace(oauthSecretId))
|
||||
{
|
||||
try
|
||||
{
|
||||
var secret = await bws.GetSecretAsync(oauthSecretId);
|
||||
oauthClientSecret = secret.Value;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Could not retrieve OAuth secret from Bitwarden for {Abbrev}", abbrev);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
adminPassword = await settings.GetAsync($"Instance.{abbrev}.AdminPassword");
|
||||
}
|
||||
|
||||
return new InstanceCredentials
|
||||
{
|
||||
AdminUsername = adminUsername,
|
||||
AdminPassword = adminPassword,
|
||||
OAuthClientId = oauthClientId,
|
||||
OAuthClientSecret = oauthClientSecret,
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private static string GeneratePassword(int length)
|
||||
{
|
||||
const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*";
|
||||
return RandomNumberGenerator.GetString(chars, length);
|
||||
}
|
||||
|
||||
private static async Task<int> GetXiboUserIdAsync(
|
||||
XiboApiService xibo,
|
||||
string instanceUrl,
|
||||
string clientId,
|
||||
string clientSecret,
|
||||
string targetUsername)
|
||||
{
|
||||
// This is a lightweight HTTP GET to fetch the user list and find by name.
|
||||
// We use HttpClient directly since XiboApiService doesn't expose this as a standalone call.
|
||||
using var http = new System.Net.Http.HttpClient();
|
||||
var baseUrl = instanceUrl.TrimEnd('/');
|
||||
|
||||
// Get token first via the TestConnectionAsync pattern
|
||||
var form = new FormUrlEncodedContent(new[]
|
||||
{
|
||||
new KeyValuePair<string, string>("grant_type", "client_credentials"),
|
||||
new KeyValuePair<string, string>("client_id", clientId),
|
||||
new KeyValuePair<string, string>("client_secret", clientSecret),
|
||||
});
|
||||
|
||||
var tokenResp = await http.PostAsync($"{baseUrl}/api/authorize/access_token", form);
|
||||
tokenResp.EnsureSuccessStatusCode();
|
||||
|
||||
using var tokenDoc = await System.Text.Json.JsonDocument.ParseAsync(
|
||||
await tokenResp.Content.ReadAsStreamAsync());
|
||||
var token = tokenDoc.RootElement.GetProperty("access_token").GetString()!;
|
||||
|
||||
http.DefaultRequestHeaders.Authorization =
|
||||
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
var usersResp = await http.GetAsync($"{baseUrl}/api/user?userName={Uri.EscapeDataString(targetUsername)}");
|
||||
usersResp.EnsureSuccessStatusCode();
|
||||
|
||||
using var usersDoc = await System.Text.Json.JsonDocument.ParseAsync(
|
||||
await usersResp.Content.ReadAsStreamAsync());
|
||||
foreach (var user in usersDoc.RootElement.EnumerateArray())
|
||||
{
|
||||
var name = user.GetProperty("userName").GetString();
|
||||
if (string.Equals(name, targetUsername, StringComparison.OrdinalIgnoreCase))
|
||||
return user.GetProperty("userId").GetInt32();
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"Xibo user '{targetUsername}' not found. Post-instance init may not have completed yet.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Snapshot of provisioned credentials for a CMS instance.</summary>
|
||||
public class InstanceCredentials
|
||||
{
|
||||
public string AdminUsername { get; set; } = string.Empty;
|
||||
public string? AdminPassword { get; set; }
|
||||
public string? OAuthClientId { get; set; }
|
||||
public string? OAuthClientSecret { get; set; }
|
||||
|
||||
public bool HasAdminPassword => !string.IsNullOrWhiteSpace(AdminPassword);
|
||||
public bool HasOAuthCredentials => !string.IsNullOrWhiteSpace(OAuthClientId) &&
|
||||
!string.IsNullOrWhiteSpace(OAuthClientSecret);
|
||||
}
|
||||
@@ -68,12 +68,31 @@ public class SettingsService
|
||||
public const string DefaultPhpUploadMaxFilesize = "Defaults.PhpUploadMaxFilesize";
|
||||
public const string DefaultPhpMaxExecutionTime = "Defaults.PhpMaxExecutionTime";
|
||||
|
||||
// Bitwarden Secrets Manager
|
||||
public const string CatBitwarden = "Bitwarden";
|
||||
public const string BitwardenIdentityUrl = "Bitwarden.IdentityUrl";
|
||||
public const string BitwardenApiUrl = "Bitwarden.ApiUrl";
|
||||
public const string BitwardenAccessToken = "Bitwarden.AccessToken";
|
||||
public const string BitwardenOrganizationId = "Bitwarden.OrganizationId";
|
||||
public const string BitwardenProjectId = "Bitwarden.ProjectId";
|
||||
|
||||
// Xibo bootstrap OAuth2 credentials (one-time global setup for orchestrator API access)
|
||||
public const string CatXibo = "Xibo";
|
||||
public const string XiboBootstrapClientId = "Xibo.BootstrapClientId";
|
||||
public const string XiboBootstrapClientSecret = "Xibo.BootstrapClientSecret";
|
||||
|
||||
// Instance-specific (keyed by abbreviation)
|
||||
/// <summary>
|
||||
/// Builds a per-instance settings key for the MySQL password.
|
||||
/// Stored encrypted via DataProtection so it can be retrieved on update/redeploy.
|
||||
/// </summary>
|
||||
public static string InstanceMySqlPassword(string abbrev) => $"Instance.{abbrev}.MySqlPassword";
|
||||
public static string InstanceMySqlPassword(string abbrev) => $"Instance.{abbrev}.MySqlPassword";
|
||||
/// <summary>Bitwarden secret ID for the instance's OTS admin password.</summary>
|
||||
public static string InstanceAdminPasswordSecretId(string abbrev) => $"Instance.{abbrev}.AdminPasswordBwsId";
|
||||
/// <summary>Bitwarden secret ID for the instance's Xibo OAuth2 client secret.</summary>
|
||||
public static string InstanceOAuthSecretId(string abbrev) => $"Instance.{abbrev}.OAuthSecretBwsId";
|
||||
/// <summary>Xibo OAuth2 client_id generated for this instance's OTS application.</summary>
|
||||
public static string InstanceOAuthClientId(string abbrev) => $"Instance.{abbrev}.OAuthClientId";
|
||||
public const string CatInstance = "Instance";
|
||||
|
||||
public SettingsService(
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using OTSSignsOrchestrator.Core.Configuration;
|
||||
@@ -5,7 +9,13 @@ using OTSSignsOrchestrator.Core.Configuration;
|
||||
namespace OTSSignsOrchestrator.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Tests connectivity to deployed Xibo CMS instances using OAuth2.
|
||||
/// Provides connectivity testing and administrative operations against deployed Xibo CMS instances.
|
||||
///
|
||||
/// Bootstrap flow:
|
||||
/// 1. A Xibo OAuth2 application with client_credentials grant must be created once
|
||||
/// (stored in Settings → Xibo.BootstrapClientId / Xibo.BootstrapClientSecret).
|
||||
/// 2. After a new instance is deployed, PostInstanceInitService calls into this service
|
||||
/// to create the OTS admin user, register a dedicated OAuth2 app, and set the theme.
|
||||
/// </summary>
|
||||
public class XiboApiService
|
||||
{
|
||||
@@ -23,7 +33,11 @@ public class XiboApiService
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<XiboTestResult> TestConnectionAsync(string instanceUrl, string username, string password)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Connection test
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public async Task<XiboTestResult> TestConnectionAsync(string instanceUrl, string clientId, string clientSecret)
|
||||
{
|
||||
_logger.LogInformation("Testing Xibo connection to {InstanceUrl}", instanceUrl);
|
||||
|
||||
@@ -32,43 +46,22 @@ public class XiboApiService
|
||||
|
||||
try
|
||||
{
|
||||
var baseUrl = instanceUrl.TrimEnd('/');
|
||||
var tokenUrl = $"{baseUrl}/api/authorize/access_token";
|
||||
|
||||
var formContent = new FormUrlEncodedContent(new[]
|
||||
{
|
||||
new KeyValuePair<string, string>("grant_type", "client_credentials"),
|
||||
new KeyValuePair<string, string>("client_id", username),
|
||||
new KeyValuePair<string, string>("client_secret", password)
|
||||
});
|
||||
|
||||
var response = await client.PostAsync(tokenUrl, formContent);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogInformation("Xibo connection test succeeded for {InstanceUrl}", instanceUrl);
|
||||
return new XiboTestResult
|
||||
{
|
||||
IsValid = true,
|
||||
Message = "Connected successfully.",
|
||||
HttpStatus = (int)response.StatusCode
|
||||
};
|
||||
}
|
||||
|
||||
_logger.LogWarning("Xibo connection test failed: {InstanceUrl} | status={StatusCode}",
|
||||
instanceUrl, (int)response.StatusCode);
|
||||
|
||||
var token = await GetTokenAsync(instanceUrl, clientId, clientSecret, client);
|
||||
_logger.LogInformation("Xibo connection test succeeded for {InstanceUrl}", instanceUrl);
|
||||
return new XiboTestResult
|
||||
{
|
||||
IsValid = false,
|
||||
Message = response.StatusCode switch
|
||||
{
|
||||
System.Net.HttpStatusCode.Unauthorized => "Invalid Xibo credentials.",
|
||||
System.Net.HttpStatusCode.Forbidden => "User lacks API permissions.",
|
||||
System.Net.HttpStatusCode.ServiceUnavailable => "Xibo instance not ready.",
|
||||
_ => $"Unexpected response: {(int)response.StatusCode}"
|
||||
},
|
||||
HttpStatus = (int)response.StatusCode
|
||||
IsValid = true,
|
||||
Message = "Connected successfully.",
|
||||
HttpStatus = 200
|
||||
};
|
||||
}
|
||||
catch (XiboAuthException ex)
|
||||
{
|
||||
return new XiboTestResult
|
||||
{
|
||||
IsValid = false,
|
||||
Message = ex.Message,
|
||||
HttpStatus = ex.HttpStatus
|
||||
};
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
@@ -80,11 +73,261 @@ public class XiboApiService
|
||||
return new XiboTestResult { IsValid = false, Message = $"Cannot reach Xibo instance: {ex.Message}" };
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Health / readiness
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Polls <paramref name="instanceUrl"/> until Xibo returns a 200 from its
|
||||
/// <c>/about</c> endpoint or <paramref name="timeout"/> elapses.
|
||||
/// </summary>
|
||||
public async Task<bool> WaitForReadyAsync(
|
||||
string instanceUrl,
|
||||
TimeSpan timeout,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
var baseUrl = instanceUrl.TrimEnd('/');
|
||||
var client = _httpClientFactory.CreateClient("XiboHealth");
|
||||
client.Timeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
_logger.LogInformation("Waiting for Xibo instance to become ready: {Url}", baseUrl);
|
||||
|
||||
while (DateTime.UtcNow < deadline && !ct.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await client.GetAsync($"{baseUrl}/api/about", ct);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogInformation("Xibo is ready: {Url}", baseUrl);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch { /* not yet available */ }
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(10), ct);
|
||||
}
|
||||
|
||||
_logger.LogWarning("Xibo did not become ready within {Timeout}: {Url}", timeout, baseUrl);
|
||||
return false;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Admin user
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new super-admin user in the Xibo instance and returns its numeric ID.
|
||||
/// </summary>
|
||||
public async Task<int> CreateAdminUserAsync(
|
||||
string instanceUrl,
|
||||
string bootstrapClientId,
|
||||
string bootstrapClientSecret,
|
||||
string newUsername,
|
||||
string newPassword,
|
||||
string email)
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient("XiboApi");
|
||||
var baseUrl = instanceUrl.TrimEnd('/');
|
||||
|
||||
var token = await GetTokenAsync(baseUrl, bootstrapClientId, bootstrapClientSecret, client);
|
||||
SetBearer(client, token);
|
||||
|
||||
var form = new FormUrlEncodedContent(new[]
|
||||
{
|
||||
new KeyValuePair<string, string>("userName", newUsername),
|
||||
new KeyValuePair<string, string>("email", email),
|
||||
new KeyValuePair<string, string>("userTypeId", "1"), // Super Admin
|
||||
new KeyValuePair<string, string>("homePageId", "1"),
|
||||
new KeyValuePair<string, string>("libraryQuota", "0"),
|
||||
new KeyValuePair<string, string>("groupId", "1"),
|
||||
new KeyValuePair<string, string>("newUserPassword", newPassword),
|
||||
new KeyValuePair<string, string>("retypeNewUserPassword", newPassword),
|
||||
new KeyValuePair<string, string>("isPasswordChangeRequired", "0"),
|
||||
});
|
||||
|
||||
var response = await client.PostAsync($"{baseUrl}/api/user", form);
|
||||
await EnsureSuccessAsync(response, "create Xibo admin user");
|
||||
|
||||
using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
|
||||
var userId = doc.RootElement.GetProperty("userId").GetInt32();
|
||||
|
||||
_logger.LogInformation("Xibo admin user created: username={Username}, userId={UserId}", newUsername, userId);
|
||||
return userId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Changes the password of an existing Xibo user.
|
||||
/// </summary>
|
||||
public async Task RotateUserPasswordAsync(
|
||||
string instanceUrl,
|
||||
string bootstrapClientId,
|
||||
string bootstrapClientSecret,
|
||||
int userId,
|
||||
string newPassword)
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient("XiboApi");
|
||||
var baseUrl = instanceUrl.TrimEnd('/');
|
||||
|
||||
var token = await GetTokenAsync(baseUrl, bootstrapClientId, bootstrapClientSecret, client);
|
||||
SetBearer(client, token);
|
||||
|
||||
var form = new FormUrlEncodedContent(new[]
|
||||
{
|
||||
new KeyValuePair<string, string>("newUserPassword", newPassword),
|
||||
new KeyValuePair<string, string>("retypeNewUserPassword", newPassword),
|
||||
});
|
||||
|
||||
var response = await client.PutAsync($"{baseUrl}/api/user/{userId}", form);
|
||||
await EnsureSuccessAsync(response, "rotate Xibo user password");
|
||||
|
||||
_logger.LogInformation("Xibo user password rotated: userId={UserId}", userId);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// OAuth2 application
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Registers a new client_credentials OAuth2 application in Xibo and returns
|
||||
/// the generated client_id and client_secret.
|
||||
/// </summary>
|
||||
public async Task<(string ClientId, string ClientSecret)> RegisterOAuthClientAsync(
|
||||
string instanceUrl,
|
||||
string bootstrapClientId,
|
||||
string bootstrapClientSecret,
|
||||
string appName)
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient("XiboApi");
|
||||
var baseUrl = instanceUrl.TrimEnd('/');
|
||||
|
||||
var token = await GetTokenAsync(baseUrl, bootstrapClientId, bootstrapClientSecret, client);
|
||||
SetBearer(client, token);
|
||||
|
||||
var form = new FormUrlEncodedContent(new[]
|
||||
{
|
||||
new KeyValuePair<string, string>("name", appName),
|
||||
new KeyValuePair<string, string>("clientId", Guid.NewGuid().ToString("N")),
|
||||
new KeyValuePair<string, string>("confidential", "1"),
|
||||
new KeyValuePair<string, string>("authCode", "0"),
|
||||
new KeyValuePair<string, string>("clientCredentials", "1"),
|
||||
});
|
||||
|
||||
var response = await client.PostAsync($"{baseUrl}/api/application", form);
|
||||
await EnsureSuccessAsync(response, "register Xibo OAuth2 application");
|
||||
|
||||
using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
|
||||
var root = doc.RootElement;
|
||||
var cid = root.GetProperty("key").GetString()
|
||||
?? throw new InvalidOperationException("Xibo application 'key' missing in response.");
|
||||
var secret = root.GetProperty("secret").GetString()
|
||||
?? throw new InvalidOperationException("Xibo application 'secret' missing in response.");
|
||||
|
||||
_logger.LogInformation("Xibo OAuth2 application registered: name={Name}, clientId={ClientId}", appName, cid);
|
||||
return (cid, secret);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Theme
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Sets the active CMS theme by writing the THEME_FOLDER setting.
|
||||
/// </summary>
|
||||
public async Task SetThemeAsync(
|
||||
string instanceUrl,
|
||||
string bootstrapClientId,
|
||||
string bootstrapClientSecret,
|
||||
string themeFolderName = "otssigns")
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient("XiboApi");
|
||||
var baseUrl = instanceUrl.TrimEnd('/');
|
||||
|
||||
var token = await GetTokenAsync(baseUrl, bootstrapClientId, bootstrapClientSecret, client);
|
||||
SetBearer(client, token);
|
||||
|
||||
// Xibo stores settings as an array: settings[THEME_FOLDER]=otssigns
|
||||
var form = new FormUrlEncodedContent(new[]
|
||||
{
|
||||
new KeyValuePair<string, string>("settings[THEME_FOLDER]", themeFolderName),
|
||||
});
|
||||
|
||||
var response = await client.PostAsync($"{baseUrl}/api/admin/setting", form);
|
||||
await EnsureSuccessAsync(response, "set Xibo theme");
|
||||
|
||||
_logger.LogInformation("Xibo theme set to: {Theme}", themeFolderName);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private async Task<string> GetTokenAsync(
|
||||
string baseUrl,
|
||||
string clientId,
|
||||
string clientSecret,
|
||||
HttpClient client)
|
||||
{
|
||||
var tokenUrl = $"{baseUrl}/api/authorize/access_token";
|
||||
var form = new FormUrlEncodedContent(new[]
|
||||
{
|
||||
new KeyValuePair<string, string>("grant_type", "client_credentials"),
|
||||
new KeyValuePair<string, string>("client_id", clientId),
|
||||
new KeyValuePair<string, string>("client_secret", clientSecret),
|
||||
});
|
||||
|
||||
var response = await client.PostAsync(tokenUrl, form);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new XiboAuthException(
|
||||
response.StatusCode switch
|
||||
{
|
||||
System.Net.HttpStatusCode.Unauthorized => "Invalid Xibo credentials.",
|
||||
System.Net.HttpStatusCode.Forbidden => "User lacks API permissions.",
|
||||
System.Net.HttpStatusCode.ServiceUnavailable => "Xibo instance not ready.",
|
||||
_ => $"Unexpected response: {(int)response.StatusCode}"
|
||||
},
|
||||
(int)response.StatusCode);
|
||||
}
|
||||
|
||||
using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
|
||||
var aToken = doc.RootElement.GetProperty("access_token").GetString()
|
||||
?? throw new InvalidOperationException("access_token missing in Xibo token response.");
|
||||
return aToken;
|
||||
}
|
||||
|
||||
private static void SetBearer(HttpClient client, string token)
|
||||
=> client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
private static async Task EnsureSuccessAsync(HttpResponseMessage response, string operation)
|
||||
{
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
throw new InvalidOperationException(
|
||||
$"Xibo API call '{operation}' failed: {(int)response.StatusCode} — {body}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Result / exception types
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public class XiboTestResult
|
||||
{
|
||||
public bool IsValid { get; set; }
|
||||
public string Message { get; set; } = string.Empty;
|
||||
public int HttpStatus { get; set; }
|
||||
public bool IsValid { get; set; }
|
||||
public string Message { get; set; } = string.Empty;
|
||||
public int HttpStatus { get; set; }
|
||||
}
|
||||
|
||||
public class XiboAuthException : Exception
|
||||
{
|
||||
public int HttpStatus { get; }
|
||||
public XiboAuthException(string message, int httpStatus) : base(message)
|
||||
=> HttpStatus = httpStatus;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user