Files
OTSSignsOrchestrator/OTSSignsOrchestrator.Core/Services/BitwardenSecretService.cs
Matt Batchelder a1c987ff21 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.
2026-02-25 08:05:44 -05:00

256 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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; }
}
}