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; } } }