up
	
		
			
	
		
	
	
		
	
		
			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
				
			
		
		
	
	
				
					
				
			
		
			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
				
			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