up
This commit is contained in:
		
							
								
								
									
										213
									
								
								src/StellaOps.Cli/Services/AuthorityRevocationClient.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										213
									
								
								src/StellaOps.Cli/Services/AuthorityRevocationClient.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,213 @@
 | 
			
		||||
using System;
 | 
			
		||||
using System.Buffers.Text;
 | 
			
		||||
using System.Net.Http;
 | 
			
		||||
using System.Net.Http.Headers;
 | 
			
		||||
using System.Text.Json;
 | 
			
		||||
using System.Text.Json.Serialization;
 | 
			
		||||
using System.Threading;
 | 
			
		||||
using System.Threading.Tasks;
 | 
			
		||||
using Microsoft.Extensions.Logging;
 | 
			
		||||
using StellaOps.Auth.Client;
 | 
			
		||||
using StellaOps.Cli.Configuration;
 | 
			
		||||
using StellaOps.Cli.Services.Models;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Cli.Services;
 | 
			
		||||
 | 
			
		||||
internal sealed class AuthorityRevocationClient : IAuthorityRevocationClient
 | 
			
		||||
{
 | 
			
		||||
    private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
 | 
			
		||||
    private static readonly TimeSpan TokenRefreshSkew = TimeSpan.FromSeconds(30);
 | 
			
		||||
 | 
			
		||||
    private readonly HttpClient httpClient;
 | 
			
		||||
    private readonly StellaOpsCliOptions options;
 | 
			
		||||
    private readonly ILogger<AuthorityRevocationClient> logger;
 | 
			
		||||
    private readonly IStellaOpsTokenClient? tokenClient;
 | 
			
		||||
    private readonly object tokenSync = new();
 | 
			
		||||
 | 
			
		||||
    private string? cachedAccessToken;
 | 
			
		||||
    private DateTimeOffset cachedAccessTokenExpiresAt = DateTimeOffset.MinValue;
 | 
			
		||||
 | 
			
		||||
    public AuthorityRevocationClient(
 | 
			
		||||
        HttpClient httpClient,
 | 
			
		||||
        StellaOpsCliOptions options,
 | 
			
		||||
        ILogger<AuthorityRevocationClient> logger,
 | 
			
		||||
        IStellaOpsTokenClient? tokenClient = null)
 | 
			
		||||
    {
 | 
			
		||||
        this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
 | 
			
		||||
        this.options = options ?? throw new ArgumentNullException(nameof(options));
 | 
			
		||||
        this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
 | 
			
		||||
        this.tokenClient = tokenClient;
 | 
			
		||||
 | 
			
		||||
        if (!string.IsNullOrWhiteSpace(options.Authority?.Url) && httpClient.BaseAddress is null && Uri.TryCreate(options.Authority.Url, UriKind.Absolute, out var authorityUri))
 | 
			
		||||
        {
 | 
			
		||||
            httpClient.BaseAddress = authorityUri;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async Task<AuthorityRevocationExportResult> ExportAsync(bool verbose, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        EnsureAuthorityConfigured();
 | 
			
		||||
 | 
			
		||||
        using var request = new HttpRequestMessage(HttpMethod.Get, "internal/revocations/export");
 | 
			
		||||
        var accessToken = await AcquireAccessTokenAsync(cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        if (!string.IsNullOrWhiteSpace(accessToken))
 | 
			
		||||
        {
 | 
			
		||||
            request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        if (!response.IsSuccessStatusCode)
 | 
			
		||||
        {
 | 
			
		||||
            var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
 | 
			
		||||
            var message = $"Authority export request failed with {(int)response.StatusCode} {response.ReasonPhrase}: {body}";
 | 
			
		||||
            throw new InvalidOperationException(message);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var payload = await JsonSerializer.DeserializeAsync<ExportResponseDto>(
 | 
			
		||||
            await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false),
 | 
			
		||||
            SerializerOptions,
 | 
			
		||||
            cancellationToken).ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
        if (payload is null)
 | 
			
		||||
        {
 | 
			
		||||
            throw new InvalidOperationException("Authority export response payload was empty.");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var bundleBytes = Convert.FromBase64String(payload.Bundle.Data);
 | 
			
		||||
        var digest = payload.Digest?.Value ?? string.Empty;
 | 
			
		||||
 | 
			
		||||
        if (verbose)
 | 
			
		||||
        {
 | 
			
		||||
            logger.LogInformation("Received revocation export sequence {Sequence} (sha256:{Digest}, signing key {KeyId}).", payload.Sequence, digest, payload.SigningKeyId ?? "<unspecified>");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return new AuthorityRevocationExportResult
 | 
			
		||||
        {
 | 
			
		||||
            BundleBytes = bundleBytes,
 | 
			
		||||
            Signature = payload.Signature?.Value ?? string.Empty,
 | 
			
		||||
            Digest = digest,
 | 
			
		||||
            Sequence = payload.Sequence,
 | 
			
		||||
            IssuedAt = payload.IssuedAt,
 | 
			
		||||
            SigningKeyId = payload.SigningKeyId
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private async Task<string?> AcquireAccessTokenAsync(CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        if (tokenClient is null)
 | 
			
		||||
        {
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        lock (tokenSync)
 | 
			
		||||
        {
 | 
			
		||||
            if (!string.IsNullOrEmpty(cachedAccessToken) && cachedAccessTokenExpiresAt - TokenRefreshSkew > DateTimeOffset.UtcNow)
 | 
			
		||||
            {
 | 
			
		||||
                return cachedAccessToken;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var scope = AuthorityTokenUtilities.ResolveScope(options);
 | 
			
		||||
        var token = await RequestAccessTokenAsync(scope, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
        lock (tokenSync)
 | 
			
		||||
        {
 | 
			
		||||
            cachedAccessToken = token.AccessToken;
 | 
			
		||||
            cachedAccessTokenExpiresAt = token.ExpiresAtUtc;
 | 
			
		||||
            return cachedAccessToken;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private async Task<StellaOpsTokenResult> RequestAccessTokenAsync(string scope, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        if (options.Authority is null)
 | 
			
		||||
        {
 | 
			
		||||
            throw new InvalidOperationException("Authority credentials are not configured.");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!string.IsNullOrWhiteSpace(options.Authority.Username))
 | 
			
		||||
        {
 | 
			
		||||
            if (string.IsNullOrWhiteSpace(options.Authority.Password))
 | 
			
		||||
            {
 | 
			
		||||
                throw new InvalidOperationException("Authority password must be configured or run 'auth login'.");
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            return await tokenClient!.RequestPasswordTokenAsync(
 | 
			
		||||
                options.Authority.Username,
 | 
			
		||||
                options.Authority.Password!,
 | 
			
		||||
                scope,
 | 
			
		||||
                cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return await tokenClient!.RequestClientCredentialsTokenAsync(scope, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private void EnsureAuthorityConfigured()
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(options.Authority?.Url))
 | 
			
		||||
        {
 | 
			
		||||
            throw new InvalidOperationException("Authority URL is not configured. Set STELLAOPS_AUTHORITY_URL or update stellaops.yaml.");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (httpClient.BaseAddress is null)
 | 
			
		||||
        {
 | 
			
		||||
            if (!Uri.TryCreate(options.Authority.Url, UriKind.Absolute, out var baseUri))
 | 
			
		||||
            {
 | 
			
		||||
                throw new InvalidOperationException("Authority URL is invalid.");
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            httpClient.BaseAddress = baseUri;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private sealed class ExportResponseDto
 | 
			
		||||
    {
 | 
			
		||||
        [JsonPropertyName("schemaVersion")]
 | 
			
		||||
        public string SchemaVersion { get; set; } = string.Empty;
 | 
			
		||||
 | 
			
		||||
        [JsonPropertyName("bundleId")]
 | 
			
		||||
        public string BundleId { get; set; } = string.Empty;
 | 
			
		||||
 | 
			
		||||
        [JsonPropertyName("sequence")]
 | 
			
		||||
        public long Sequence { get; set; }
 | 
			
		||||
 | 
			
		||||
        [JsonPropertyName("issuedAt")]
 | 
			
		||||
        public DateTimeOffset IssuedAt { get; set; }
 | 
			
		||||
 | 
			
		||||
        [JsonPropertyName("signingKeyId")]
 | 
			
		||||
        public string? SigningKeyId { get; set; }
 | 
			
		||||
 | 
			
		||||
        [JsonPropertyName("bundle")]
 | 
			
		||||
        public ExportPayloadDto Bundle { get; set; } = new();
 | 
			
		||||
 | 
			
		||||
        [JsonPropertyName("signature")]
 | 
			
		||||
        public ExportSignatureDto? Signature { get; set; }
 | 
			
		||||
 | 
			
		||||
        [JsonPropertyName("digest")]
 | 
			
		||||
        public ExportDigestDto? Digest { get; set; }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private sealed class ExportPayloadDto
 | 
			
		||||
    {
 | 
			
		||||
        [JsonPropertyName("data")]
 | 
			
		||||
        public string Data { get; set; } = string.Empty;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private sealed class ExportSignatureDto
 | 
			
		||||
    {
 | 
			
		||||
        [JsonPropertyName("algorithm")]
 | 
			
		||||
        public string Algorithm { get; set; } = string.Empty;
 | 
			
		||||
 | 
			
		||||
        [JsonPropertyName("keyId")]
 | 
			
		||||
        public string KeyId { get; set; } = string.Empty;
 | 
			
		||||
 | 
			
		||||
        [JsonPropertyName("value")]
 | 
			
		||||
        public string Value { get; set; } = string.Empty;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private sealed class ExportDigestDto
 | 
			
		||||
    {
 | 
			
		||||
        [JsonPropertyName("value")]
 | 
			
		||||
        public string Value { get; set; } = string.Empty;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
		Reference in New Issue
	
	Block a user