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