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; /// /// 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 /// public class BitwardenSecretService : IBitwardenSecretService { private readonly IHttpClientFactory _http; private readonly SettingsService _settings; private readonly ILogger _logger; // Cached bearer token (refreshed per service lifetime — transient registration is assumed) private string? _bearerToken; public BitwardenSecretService( IHttpClientFactory http, SettingsService settings, ILogger logger) { _http = http; _settings = settings; _logger = logger; } // ───────────────────────────────────────────────────────────────────────── // IBitwardenSecretService // ───────────────────────────────────────────────────────────────────────── public async Task 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 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(), 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() ?? 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 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() ?? 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(), 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> 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(); return result?.Data?.Select(s => new BitwardenSecretSummary { Id = s.Id, Key = s.Key, CreationDate = s.CreationDate }).ToList() ?? new List(); } // ───────────────────────────────────────────────────────────────────────── // Auth // ───────────────────────────────────────────────────────────────────────── /// /// 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}". /// private async Task 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.." — 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.."); 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("grant_type", "client_credentials"), new KeyValuePair("client_id", $"machine.{tokenId}"), new KeyValuePair("client_secret", clientSecret), new KeyValuePair("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() ?? throw new InvalidOperationException("Empty token response from Bitwarden."); _bearerToken = token.AccessToken; _logger.LogInformation("Bitwarden bearer token acquired."); return _bearerToken; } // ───────────────────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────────────────── private async Task GetIdentityUrlAsync() => (await _settings.GetAsync(SettingsService.BitwardenIdentityUrl, "https://identity.bitwarden.com")).TrimEnd('/'); private async Task 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? Data { get; set; } } }