Some checks failed
		
		
	
	Build Test Deploy / docs (push) Has been cancelled
				
			Build Test Deploy / deploy (push) Has been cancelled
				
			Build Test Deploy / build-test (push) Has been cancelled
				
			Build Test Deploy / authority-container (push) Has been cancelled
				
			Docs CI / lint-and-preview (push) Has been cancelled
				
			
		
			
				
	
	
		
			214 lines
		
	
	
		
			7.5 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
			
		
		
	
	
			214 lines
		
	
	
		
			7.5 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
| 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;
 | |
|     }
 | |
| }
 |