256 lines
11 KiB
C#
256 lines
11 KiB
C#
|
|
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; }
|
|||
|
|
}
|
|||
|
|
}
|