2026-02-25 17:39:17 -05:00
|
|
|
using Bitwarden.Sdk;
|
2026-02-25 08:05:44 -05:00
|
|
|
using Microsoft.Extensions.Logging;
|
2026-02-25 17:39:17 -05:00
|
|
|
using Microsoft.Extensions.Options;
|
|
|
|
|
using OTSSignsOrchestrator.Core.Configuration;
|
2026-02-25 08:05:44 -05:00
|
|
|
|
|
|
|
|
namespace OTSSignsOrchestrator.Core.Services;
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
2026-02-25 17:39:17 -05:00
|
|
|
/// Stores and retrieves secrets from Bitwarden Secrets Manager via the official Bitwarden C# SDK.
|
2026-02-25 08:05:44 -05:00
|
|
|
///
|
2026-02-25 17:39:17 -05:00
|
|
|
/// Configuration is read from <see cref="BitwardenOptions"/> (bound to appsettings.json → "Bitwarden").
|
|
|
|
|
///
|
|
|
|
|
/// The SDK state file is persisted to %APPDATA%/OTSSignsOrchestrator/bitwarden.state
|
|
|
|
|
/// so the SDK can cache its internal state across restarts.
|
2026-02-25 08:05:44 -05:00
|
|
|
/// </summary>
|
2026-02-25 17:39:17 -05:00
|
|
|
public class BitwardenSecretService : IBitwardenSecretService, IDisposable
|
2026-02-25 08:05:44 -05:00
|
|
|
{
|
2026-02-25 17:39:17 -05:00
|
|
|
private readonly IOptionsMonitor<BitwardenOptions> _optionsMonitor;
|
2026-02-25 08:05:44 -05:00
|
|
|
private readonly ILogger<BitwardenSecretService> _logger;
|
|
|
|
|
|
2026-02-25 17:39:17 -05:00
|
|
|
// Lazily created on first use (per service instance — registered as Transient).
|
|
|
|
|
private BitwardenClient? _client;
|
|
|
|
|
private string? _clientAccessToken; // track which token the client was created with
|
|
|
|
|
private bool _disposed;
|
|
|
|
|
|
|
|
|
|
/// <summary>Always returns the latest config snapshot (reloaded when appsettings.json changes).</summary>
|
|
|
|
|
private BitwardenOptions Options => _optionsMonitor.CurrentValue;
|
2026-02-25 08:05:44 -05:00
|
|
|
|
|
|
|
|
public BitwardenSecretService(
|
2026-02-25 17:39:17 -05:00
|
|
|
IOptionsMonitor<BitwardenOptions> optionsMonitor,
|
2026-02-25 08:05:44 -05:00
|
|
|
ILogger<BitwardenSecretService> logger)
|
|
|
|
|
{
|
2026-02-25 17:39:17 -05:00
|
|
|
_optionsMonitor = optionsMonitor;
|
|
|
|
|
_logger = logger;
|
2026-02-25 08:05:44 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
|
// IBitwardenSecretService
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
2026-02-25 17:39:17 -05:00
|
|
|
public Task<bool> IsConfiguredAsync()
|
2026-02-25 08:05:44 -05:00
|
|
|
{
|
2026-02-25 17:39:17 -05:00
|
|
|
var opts = Options;
|
|
|
|
|
var configured = !string.IsNullOrWhiteSpace(opts.AccessToken)
|
|
|
|
|
&& !string.IsNullOrWhiteSpace(opts.OrganizationId);
|
|
|
|
|
return Task.FromResult(configured);
|
2026-02-25 08:05:44 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<string> CreateSecretAsync(string key, string value, string note = "")
|
|
|
|
|
{
|
2026-02-25 17:39:17 -05:00
|
|
|
var client = await GetClientAsync();
|
|
|
|
|
var orgId = GetOrgId();
|
|
|
|
|
var projectIds = GetProjectIds();
|
2026-02-25 08:05:44 -05:00
|
|
|
|
2026-02-25 17:39:17 -05:00
|
|
|
var result = await Task.Run(() => client.Secrets.Create(orgId, key, value, note, projectIds));
|
2026-02-25 08:05:44 -05:00
|
|
|
|
|
|
|
|
_logger.LogInformation("Bitwarden secret created: key={Key}, id={Id}", key, result.Id);
|
2026-02-25 17:39:17 -05:00
|
|
|
return result.Id.ToString();
|
2026-02-25 08:05:44 -05:00
|
|
|
}
|
|
|
|
|
|
2026-02-25 17:39:17 -05:00
|
|
|
public async Task<string> CreateInstanceSecretAsync(string key, string value, string note = "")
|
2026-02-25 08:05:44 -05:00
|
|
|
{
|
2026-02-25 17:39:17 -05:00
|
|
|
var client = await GetClientAsync();
|
|
|
|
|
var orgId = GetOrgId();
|
|
|
|
|
var projectIds = GetInstanceProjectIds();
|
2026-02-25 08:05:44 -05:00
|
|
|
|
2026-02-25 17:39:17 -05:00
|
|
|
var result = await Task.Run(() => client.Secrets.Create(orgId, key, value, note, projectIds));
|
2026-02-25 08:05:44 -05:00
|
|
|
|
2026-02-25 17:39:17 -05:00
|
|
|
_logger.LogInformation("Bitwarden instance secret created: key={Key}, id={Id}, project={Project}",
|
|
|
|
|
key, result.Id, Options.InstanceProjectId);
|
|
|
|
|
return result.Id.ToString();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<BitwardenSecret> GetSecretAsync(string secretId)
|
|
|
|
|
{
|
|
|
|
|
var client = await GetClientAsync();
|
|
|
|
|
var result = await Task.Run(() => client.Secrets.Get(Guid.Parse(secretId)));
|
2026-02-25 08:05:44 -05:00
|
|
|
|
|
|
|
|
return new BitwardenSecret
|
|
|
|
|
{
|
2026-02-25 17:39:17 -05:00
|
|
|
Id = result.Id.ToString(),
|
2026-02-25 08:05:44 -05:00
|
|
|
Key = result.Key,
|
|
|
|
|
Value = result.Value,
|
|
|
|
|
Note = result.Note ?? string.Empty,
|
2026-02-25 17:39:17 -05:00
|
|
|
CreationDate = result.CreationDate.DateTime
|
2026-02-25 08:05:44 -05:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task UpdateSecretAsync(string secretId, string key, string value, string note = "")
|
|
|
|
|
{
|
2026-02-25 17:39:17 -05:00
|
|
|
var client = await GetClientAsync();
|
|
|
|
|
var orgId = GetOrgId();
|
|
|
|
|
var projectIds = GetProjectIds();
|
2026-02-25 08:05:44 -05:00
|
|
|
|
2026-02-25 17:39:17 -05:00
|
|
|
await Task.Run(() => client.Secrets.Update(orgId, Guid.Parse(secretId), key, value, note, projectIds));
|
2026-02-25 08:05:44 -05:00
|
|
|
|
|
|
|
|
_logger.LogInformation("Bitwarden secret updated: key={Key}, id={Id}", key, secretId);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-25 17:39:17 -05:00
|
|
|
public async Task UpdateInstanceSecretAsync(string secretId, string key, string value, string note = "")
|
2026-02-25 08:05:44 -05:00
|
|
|
{
|
2026-02-25 17:39:17 -05:00
|
|
|
var client = await GetClientAsync();
|
|
|
|
|
var orgId = GetOrgId();
|
|
|
|
|
var projectIds = GetInstanceProjectIds();
|
2026-02-25 08:05:44 -05:00
|
|
|
|
2026-02-25 17:39:17 -05:00
|
|
|
await Task.Run(() => client.Secrets.Update(orgId, Guid.Parse(secretId), key, value, note, projectIds));
|
2026-02-25 08:05:44 -05:00
|
|
|
|
2026-02-25 17:39:17 -05:00
|
|
|
_logger.LogInformation("Bitwarden instance secret updated: key={Key}, id={Id}, project={Project}",
|
|
|
|
|
key, secretId, Options.InstanceProjectId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<List<BitwardenSecretSummary>> ListSecretsAsync()
|
|
|
|
|
{
|
|
|
|
|
var client = await GetClientAsync();
|
|
|
|
|
var orgId = GetOrgId();
|
|
|
|
|
var result = await Task.Run(() => client.Secrets.List(orgId));
|
2026-02-25 08:05:44 -05:00
|
|
|
|
2026-02-25 17:39:17 -05:00
|
|
|
return result.Data?.Select(s => new BitwardenSecretSummary
|
2026-02-25 08:05:44 -05:00
|
|
|
{
|
2026-02-25 17:39:17 -05:00
|
|
|
Id = s.Id.ToString(),
|
2026-02-25 08:05:44 -05:00
|
|
|
Key = s.Key,
|
2026-02-25 17:39:17 -05:00
|
|
|
CreationDate = DateTime.MinValue
|
2026-02-25 08:05:44 -05:00
|
|
|
}).ToList() ?? new List<BitwardenSecretSummary>();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
2026-02-25 17:39:17 -05:00
|
|
|
// SDK client initialisation
|
2026-02-25 08:05:44 -05:00
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
2026-02-25 17:39:17 -05:00
|
|
|
/// Returns an authenticated <see cref="BitwardenClient"/>, creating and logging in on first use.
|
2026-02-25 08:05:44 -05:00
|
|
|
/// </summary>
|
2026-02-25 17:39:17 -05:00
|
|
|
private async Task<BitwardenClient> GetClientAsync()
|
2026-02-25 08:05:44 -05:00
|
|
|
{
|
2026-02-25 17:39:17 -05:00
|
|
|
var opts = Options;
|
2026-02-25 08:05:44 -05:00
|
|
|
|
2026-02-25 17:39:17 -05:00
|
|
|
// If credentials changed since the client was created, tear it down so we re-auth
|
|
|
|
|
if (_client is not null && _clientAccessToken != opts.AccessToken)
|
|
|
|
|
{
|
|
|
|
|
_logger.LogInformation("Bitwarden credentials changed — recreating SDK client");
|
|
|
|
|
_client.Dispose();
|
|
|
|
|
_client = null;
|
|
|
|
|
}
|
2026-02-25 08:05:44 -05:00
|
|
|
|
2026-02-25 17:39:17 -05:00
|
|
|
if (_client is not null)
|
|
|
|
|
return _client;
|
2026-02-25 08:05:44 -05:00
|
|
|
|
2026-02-25 17:39:17 -05:00
|
|
|
if (string.IsNullOrWhiteSpace(opts.AccessToken))
|
|
|
|
|
throw new InvalidOperationException("Bitwarden AccessToken is not configured. Set it in Settings → Bitwarden.");
|
2026-02-25 08:05:44 -05:00
|
|
|
|
2026-02-25 17:39:17 -05:00
|
|
|
var accessToken = opts.AccessToken;
|
|
|
|
|
var apiUrl = (opts.ApiUrl ?? "https://api.bitwarden.com").TrimEnd('/');
|
|
|
|
|
var identityUrl = (opts.IdentityUrl ?? "https://identity.bitwarden.com").TrimEnd('/');
|
2026-02-25 08:05:44 -05:00
|
|
|
|
2026-02-25 17:39:17 -05:00
|
|
|
var sdkSettings = new BitwardenSettings { ApiUrl = apiUrl, IdentityUrl = identityUrl };
|
|
|
|
|
var client = new BitwardenClient(sdkSettings);
|
2026-02-25 08:05:44 -05:00
|
|
|
|
2026-02-25 17:39:17 -05:00
|
|
|
await Task.Run(() => client.Auth.LoginAccessToken(accessToken, GetStateFilePath()));
|
2026-02-25 08:05:44 -05:00
|
|
|
|
2026-02-25 17:39:17 -05:00
|
|
|
_logger.LogInformation("Bitwarden SDK client initialised and authenticated.");
|
|
|
|
|
_client = client;
|
|
|
|
|
_clientAccessToken = accessToken;
|
|
|
|
|
return _client;
|
2026-02-25 08:05:44 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
|
// Helpers
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
2026-02-25 17:39:17 -05:00
|
|
|
private Guid GetOrgId()
|
|
|
|
|
{
|
|
|
|
|
var orgId = Options.OrganizationId;
|
|
|
|
|
if (string.IsNullOrWhiteSpace(orgId))
|
|
|
|
|
throw new InvalidOperationException("Bitwarden OrganizationId is not configured.");
|
|
|
|
|
return Guid.Parse(orgId);
|
|
|
|
|
}
|
2026-02-25 08:05:44 -05:00
|
|
|
|
2026-02-25 17:39:17 -05:00
|
|
|
private Guid[] GetProjectIds()
|
2026-02-25 08:05:44 -05:00
|
|
|
{
|
2026-02-25 17:39:17 -05:00
|
|
|
var projectId = Options.ProjectId;
|
|
|
|
|
if (string.IsNullOrWhiteSpace(projectId))
|
|
|
|
|
throw new InvalidOperationException(
|
|
|
|
|
"Bitwarden ProjectId is required. Set it in Settings → Bitwarden.");
|
|
|
|
|
return new[] { Guid.Parse(projectId) };
|
2026-02-25 08:05:44 -05:00
|
|
|
}
|
|
|
|
|
|
2026-02-25 17:39:17 -05:00
|
|
|
/// <summary>
|
|
|
|
|
/// Returns the project IDs array for instance-level secrets.
|
|
|
|
|
/// Uses <see cref="BitwardenOptions.InstanceProjectId"/> when configured,
|
|
|
|
|
/// otherwise falls back to the default <see cref="BitwardenOptions.ProjectId"/>.
|
|
|
|
|
/// </summary>
|
|
|
|
|
private Guid[] GetInstanceProjectIds()
|
2026-02-25 08:05:44 -05:00
|
|
|
{
|
2026-02-25 17:39:17 -05:00
|
|
|
var instanceProjectId = Options.InstanceProjectId;
|
|
|
|
|
if (!string.IsNullOrWhiteSpace(instanceProjectId))
|
2026-02-25 08:05:44 -05:00
|
|
|
{
|
2026-02-25 17:39:17 -05:00
|
|
|
_logger.LogDebug("Using instance Bitwarden project: {ProjectId}", instanceProjectId);
|
|
|
|
|
return new[] { Guid.Parse(instanceProjectId) };
|
2026-02-25 08:05:44 -05:00
|
|
|
}
|
|
|
|
|
|
2026-02-25 17:39:17 -05:00
|
|
|
// Fall back to the default config project
|
|
|
|
|
return GetProjectIds();
|
2026-02-25 08:05:44 -05:00
|
|
|
}
|
|
|
|
|
|
2026-02-25 17:39:17 -05:00
|
|
|
/// <summary>
|
|
|
|
|
/// Returns the path where the SDK stores its state between sessions.
|
|
|
|
|
/// Stored under %APPDATA%/OTSSignsOrchestrator/bitwarden.state.
|
|
|
|
|
/// </summary>
|
|
|
|
|
private static string GetStateFilePath()
|
2026-02-25 08:05:44 -05:00
|
|
|
{
|
2026-02-25 17:39:17 -05:00
|
|
|
var dir = Path.Combine(
|
|
|
|
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
|
|
|
|
"OTSSignsOrchestrator");
|
|
|
|
|
Directory.CreateDirectory(dir);
|
|
|
|
|
return Path.Combine(dir, "bitwarden.state");
|
2026-02-25 08:05:44 -05:00
|
|
|
}
|
|
|
|
|
|
2026-02-25 17:39:17 -05:00
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
|
// IDisposable
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
public void Dispose()
|
2026-02-25 08:05:44 -05:00
|
|
|
{
|
2026-02-25 17:39:17 -05:00
|
|
|
if (!_disposed)
|
|
|
|
|
{
|
|
|
|
|
_client?.Dispose();
|
|
|
|
|
_disposed = true;
|
|
|
|
|
}
|
2026-02-25 08:05:44 -05:00
|
|
|
}
|
|
|
|
|
}
|