Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
597 lines
24 KiB
C#
597 lines
24 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Net.Http;
|
|
using System.Net.Http.Headers;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.Extensions.Logging;
|
|
using StellaOps.Auth.Abstractions;
|
|
using StellaOps.Auth.Client;
|
|
using StellaOps.Cli.Configuration;
|
|
using StellaOps.Cli.Services.Models;
|
|
|
|
namespace StellaOps.Cli.Services;
|
|
|
|
/// <summary>
|
|
/// Client for exception governance API operations.
|
|
/// Per CLI-EXC-25-001.
|
|
/// </summary>
|
|
internal sealed class ExceptionClient : IExceptionClient
|
|
{
|
|
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<ExceptionClient> logger;
|
|
private readonly IStellaOpsTokenClient? tokenClient;
|
|
private readonly object tokenSync = new();
|
|
|
|
private string? cachedAccessToken;
|
|
private DateTimeOffset cachedAccessTokenExpiresAt = DateTimeOffset.MinValue;
|
|
|
|
public ExceptionClient(
|
|
HttpClient httpClient,
|
|
StellaOpsCliOptions options,
|
|
ILogger<ExceptionClient> 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.BackendUrl) && httpClient.BaseAddress is null)
|
|
{
|
|
if (Uri.TryCreate(options.BackendUrl, UriKind.Absolute, out var baseUri))
|
|
{
|
|
httpClient.BaseAddress = baseUri;
|
|
}
|
|
}
|
|
}
|
|
|
|
public async Task<ExceptionListResponse> ListAsync(
|
|
ExceptionListRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(request);
|
|
|
|
try
|
|
{
|
|
EnsureConfigured();
|
|
|
|
var uri = BuildListUri(request);
|
|
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
|
|
await AuthorizeRequestAsync(httpRequest, "exceptions.read", cancellationToken).ConfigureAwait(false);
|
|
|
|
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
|
logger.LogError(
|
|
"Failed to list exceptions (status {StatusCode}). Response: {Payload}",
|
|
(int)response.StatusCode,
|
|
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
|
|
|
return new ExceptionListResponse();
|
|
}
|
|
|
|
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
|
var result = await JsonSerializer
|
|
.DeserializeAsync<ExceptionListResponse>(stream, SerializerOptions, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
return result ?? new ExceptionListResponse();
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
logger.LogError(ex, "HTTP error while listing exceptions");
|
|
return new ExceptionListResponse();
|
|
}
|
|
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
|
{
|
|
logger.LogError(ex, "Request timed out while listing exceptions");
|
|
return new ExceptionListResponse();
|
|
}
|
|
}
|
|
|
|
public async Task<ExceptionInstance?> GetAsync(
|
|
string exceptionId,
|
|
string? tenant,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(exceptionId);
|
|
|
|
try
|
|
{
|
|
EnsureConfigured();
|
|
|
|
var uri = $"/api/v1/exceptions/{Uri.EscapeDataString(exceptionId)}";
|
|
if (!string.IsNullOrWhiteSpace(tenant))
|
|
{
|
|
uri += $"?tenant={Uri.EscapeDataString(tenant)}";
|
|
}
|
|
|
|
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
|
|
await AuthorizeRequestAsync(httpRequest, "exceptions.read", cancellationToken).ConfigureAwait(false);
|
|
|
|
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
|
logger.LogError(
|
|
"Failed to get exception (status {StatusCode}). Response: {Payload}",
|
|
(int)response.StatusCode,
|
|
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
|
return null;
|
|
}
|
|
|
|
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
|
return await JsonSerializer
|
|
.DeserializeAsync<ExceptionInstance>(stream, SerializerOptions, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
logger.LogError(ex, "HTTP error while getting exception");
|
|
return null;
|
|
}
|
|
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
|
{
|
|
logger.LogError(ex, "Request timed out while getting exception");
|
|
return null;
|
|
}
|
|
}
|
|
|
|
public async Task<ExceptionOperationResult> CreateAsync(
|
|
ExceptionCreateRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(request);
|
|
|
|
try
|
|
{
|
|
EnsureConfigured();
|
|
|
|
var json = JsonSerializer.Serialize(request, SerializerOptions);
|
|
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
|
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/exceptions")
|
|
{
|
|
Content = content
|
|
};
|
|
await AuthorizeRequestAsync(httpRequest, "exceptions.write", cancellationToken).ConfigureAwait(false);
|
|
|
|
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
|
logger.LogError(
|
|
"Failed to create exception (status {StatusCode}). Response: {Payload}",
|
|
(int)response.StatusCode,
|
|
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
|
|
|
return new ExceptionOperationResult
|
|
{
|
|
Success = false,
|
|
Errors = [$"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"]
|
|
};
|
|
}
|
|
|
|
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
|
var result = await JsonSerializer
|
|
.DeserializeAsync<ExceptionOperationResult>(stream, SerializerOptions, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
return result ?? new ExceptionOperationResult { Success = false, Errors = ["Empty response"] };
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
logger.LogError(ex, "HTTP error while creating exception");
|
|
return new ExceptionOperationResult
|
|
{
|
|
Success = false,
|
|
Errors = [$"Connection error: {ex.Message}"]
|
|
};
|
|
}
|
|
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
|
{
|
|
logger.LogError(ex, "Request timed out while creating exception");
|
|
return new ExceptionOperationResult
|
|
{
|
|
Success = false,
|
|
Errors = ["Request timed out"]
|
|
};
|
|
}
|
|
}
|
|
|
|
public async Task<ExceptionOperationResult> PromoteAsync(
|
|
ExceptionPromoteRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(request);
|
|
|
|
try
|
|
{
|
|
EnsureConfigured();
|
|
|
|
var json = JsonSerializer.Serialize(request, SerializerOptions);
|
|
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
|
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/exceptions/{Uri.EscapeDataString(request.ExceptionId)}/promote")
|
|
{
|
|
Content = content
|
|
};
|
|
await AuthorizeRequestAsync(httpRequest, "exceptions.approve", cancellationToken).ConfigureAwait(false);
|
|
|
|
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
|
logger.LogError(
|
|
"Failed to promote exception (status {StatusCode}). Response: {Payload}",
|
|
(int)response.StatusCode,
|
|
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
|
|
|
return new ExceptionOperationResult
|
|
{
|
|
Success = false,
|
|
Errors = [$"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"]
|
|
};
|
|
}
|
|
|
|
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
|
var result = await JsonSerializer
|
|
.DeserializeAsync<ExceptionOperationResult>(stream, SerializerOptions, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
return result ?? new ExceptionOperationResult { Success = false, Errors = ["Empty response"] };
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
logger.LogError(ex, "HTTP error while promoting exception");
|
|
return new ExceptionOperationResult
|
|
{
|
|
Success = false,
|
|
Errors = [$"Connection error: {ex.Message}"]
|
|
};
|
|
}
|
|
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
|
{
|
|
logger.LogError(ex, "Request timed out while promoting exception");
|
|
return new ExceptionOperationResult
|
|
{
|
|
Success = false,
|
|
Errors = ["Request timed out"]
|
|
};
|
|
}
|
|
}
|
|
|
|
public async Task<ExceptionOperationResult> RevokeAsync(
|
|
ExceptionRevokeRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(request);
|
|
|
|
try
|
|
{
|
|
EnsureConfigured();
|
|
|
|
var json = JsonSerializer.Serialize(request, SerializerOptions);
|
|
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
|
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/exceptions/{Uri.EscapeDataString(request.ExceptionId)}/revoke")
|
|
{
|
|
Content = content
|
|
};
|
|
await AuthorizeRequestAsync(httpRequest, "exceptions.write", cancellationToken).ConfigureAwait(false);
|
|
|
|
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
|
logger.LogError(
|
|
"Failed to revoke exception (status {StatusCode}). Response: {Payload}",
|
|
(int)response.StatusCode,
|
|
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
|
|
|
return new ExceptionOperationResult
|
|
{
|
|
Success = false,
|
|
Errors = [$"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"]
|
|
};
|
|
}
|
|
|
|
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
|
var result = await JsonSerializer
|
|
.DeserializeAsync<ExceptionOperationResult>(stream, SerializerOptions, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
return result ?? new ExceptionOperationResult { Success = false, Errors = ["Empty response"] };
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
logger.LogError(ex, "HTTP error while revoking exception");
|
|
return new ExceptionOperationResult
|
|
{
|
|
Success = false,
|
|
Errors = [$"Connection error: {ex.Message}"]
|
|
};
|
|
}
|
|
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
|
{
|
|
logger.LogError(ex, "Request timed out while revoking exception");
|
|
return new ExceptionOperationResult
|
|
{
|
|
Success = false,
|
|
Errors = ["Request timed out"]
|
|
};
|
|
}
|
|
}
|
|
|
|
public async Task<ExceptionImportResult> ImportAsync(
|
|
ExceptionImportRequest request,
|
|
Stream ndjsonStream,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(request);
|
|
ArgumentNullException.ThrowIfNull(ndjsonStream);
|
|
|
|
try
|
|
{
|
|
EnsureConfigured();
|
|
|
|
using var content = new MultipartFormDataContent();
|
|
var streamContent = new StreamContent(ndjsonStream);
|
|
streamContent.Headers.ContentType = new MediaTypeHeaderValue("application/x-ndjson");
|
|
content.Add(streamContent, "file", "exceptions.ndjson");
|
|
content.Add(new StringContent(request.Tenant), "tenant");
|
|
content.Add(new StringContent(request.Stage.ToString().ToLowerInvariant()), "stage");
|
|
if (!string.IsNullOrWhiteSpace(request.Source))
|
|
{
|
|
content.Add(new StringContent(request.Source), "source");
|
|
}
|
|
|
|
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/exceptions/import")
|
|
{
|
|
Content = content
|
|
};
|
|
await AuthorizeRequestAsync(httpRequest, "exceptions.write", cancellationToken).ConfigureAwait(false);
|
|
|
|
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
|
logger.LogError(
|
|
"Failed to import exceptions (status {StatusCode}). Response: {Payload}",
|
|
(int)response.StatusCode,
|
|
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
|
|
|
return new ExceptionImportResult
|
|
{
|
|
Success = false,
|
|
Errors = [new ExceptionImportError { Line = 0, Message = $"API returned {(int)response.StatusCode}: {response.ReasonPhrase}" }]
|
|
};
|
|
}
|
|
|
|
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
|
var result = await JsonSerializer
|
|
.DeserializeAsync<ExceptionImportResult>(stream, SerializerOptions, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
return result ?? new ExceptionImportResult { Success = false, Errors = [new ExceptionImportError { Line = 0, Message = "Empty response" }] };
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
logger.LogError(ex, "HTTP error while importing exceptions");
|
|
return new ExceptionImportResult
|
|
{
|
|
Success = false,
|
|
Errors = [new ExceptionImportError { Line = 0, Message = $"Connection error: {ex.Message}" }]
|
|
};
|
|
}
|
|
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
|
{
|
|
logger.LogError(ex, "Request timed out while importing exceptions");
|
|
return new ExceptionImportResult
|
|
{
|
|
Success = false,
|
|
Errors = [new ExceptionImportError { Line = 0, Message = "Request timed out" }]
|
|
};
|
|
}
|
|
}
|
|
|
|
public async Task<(Stream Content, ExceptionExportManifest? Manifest)> ExportAsync(
|
|
ExceptionExportRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(request);
|
|
|
|
try
|
|
{
|
|
EnsureConfigured();
|
|
|
|
var queryParams = new List<string>();
|
|
if (!string.IsNullOrWhiteSpace(request.Tenant))
|
|
{
|
|
queryParams.Add($"tenant={Uri.EscapeDataString(request.Tenant)}");
|
|
}
|
|
if (request.Statuses is { Count: > 0 })
|
|
{
|
|
foreach (var status in request.Statuses)
|
|
{
|
|
queryParams.Add($"status={Uri.EscapeDataString(status)}");
|
|
}
|
|
}
|
|
queryParams.Add($"format={Uri.EscapeDataString(request.Format)}");
|
|
queryParams.Add($"includeManifest={request.IncludeManifest.ToString().ToLowerInvariant()}");
|
|
queryParams.Add($"signed={request.Signed.ToString().ToLowerInvariant()}");
|
|
|
|
var query = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : string.Empty;
|
|
var uri = $"/api/v1/exceptions/export{query}";
|
|
|
|
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
|
|
await AuthorizeRequestAsync(httpRequest, "exceptions.read", cancellationToken).ConfigureAwait(false);
|
|
|
|
var response = await httpClient.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
|
logger.LogError(
|
|
"Failed to export exceptions (status {StatusCode}). Response: {Payload}",
|
|
(int)response.StatusCode,
|
|
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
|
|
|
return (Stream.Null, null);
|
|
}
|
|
|
|
// Parse manifest from header if present
|
|
ExceptionExportManifest? manifest = null;
|
|
if (response.Headers.TryGetValues("X-Export-Manifest", out var manifestValues))
|
|
{
|
|
var manifestJson = string.Join("", manifestValues);
|
|
if (!string.IsNullOrWhiteSpace(manifestJson))
|
|
{
|
|
try
|
|
{
|
|
manifest = JsonSerializer.Deserialize<ExceptionExportManifest>(manifestJson, SerializerOptions);
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
// Ignore parse errors for optional header
|
|
}
|
|
}
|
|
}
|
|
|
|
var contentStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
|
return (contentStream, manifest);
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
logger.LogError(ex, "HTTP error while exporting exceptions");
|
|
return (Stream.Null, null);
|
|
}
|
|
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
|
{
|
|
logger.LogError(ex, "Request timed out while exporting exceptions");
|
|
return (Stream.Null, null);
|
|
}
|
|
}
|
|
|
|
private static string BuildListUri(ExceptionListRequest request)
|
|
{
|
|
var queryParams = new List<string>();
|
|
|
|
if (!string.IsNullOrWhiteSpace(request.Tenant))
|
|
{
|
|
queryParams.Add($"tenant={Uri.EscapeDataString(request.Tenant)}");
|
|
}
|
|
if (!string.IsNullOrWhiteSpace(request.Vuln))
|
|
{
|
|
queryParams.Add($"vuln={Uri.EscapeDataString(request.Vuln)}");
|
|
}
|
|
if (!string.IsNullOrWhiteSpace(request.ScopeType))
|
|
{
|
|
queryParams.Add($"scopeType={Uri.EscapeDataString(request.ScopeType)}");
|
|
}
|
|
if (!string.IsNullOrWhiteSpace(request.ScopeValue))
|
|
{
|
|
queryParams.Add($"scopeValue={Uri.EscapeDataString(request.ScopeValue)}");
|
|
}
|
|
if (request.Statuses is { Count: > 0 })
|
|
{
|
|
foreach (var status in request.Statuses)
|
|
{
|
|
queryParams.Add($"status={Uri.EscapeDataString(status)}");
|
|
}
|
|
}
|
|
if (!string.IsNullOrWhiteSpace(request.Owner))
|
|
{
|
|
queryParams.Add($"owner={Uri.EscapeDataString(request.Owner)}");
|
|
}
|
|
if (!string.IsNullOrWhiteSpace(request.EffectType))
|
|
{
|
|
queryParams.Add($"effectType={Uri.EscapeDataString(request.EffectType)}");
|
|
}
|
|
if (request.ExpiringBefore.HasValue)
|
|
{
|
|
queryParams.Add($"expiringBefore={Uri.EscapeDataString(request.ExpiringBefore.Value.ToString("O"))}");
|
|
}
|
|
if (request.IncludeExpired)
|
|
{
|
|
queryParams.Add("includeExpired=true");
|
|
}
|
|
queryParams.Add($"pageSize={request.PageSize}");
|
|
if (!string.IsNullOrWhiteSpace(request.PageToken))
|
|
{
|
|
queryParams.Add($"pageToken={Uri.EscapeDataString(request.PageToken)}");
|
|
}
|
|
|
|
var query = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : string.Empty;
|
|
return $"/api/v1/exceptions{query}";
|
|
}
|
|
|
|
private void EnsureConfigured()
|
|
{
|
|
if (string.IsNullOrWhiteSpace(options.BackendUrl) && httpClient.BaseAddress is null)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"Backend URL not configured. Set STELLAOPS_BACKEND_URL or use --backend-url.");
|
|
}
|
|
}
|
|
|
|
private async Task AuthorizeRequestAsync(HttpRequestMessage request, string scope, CancellationToken cancellationToken)
|
|
{
|
|
var token = await GetAccessTokenAsync(scope, cancellationToken).ConfigureAwait(false);
|
|
if (!string.IsNullOrWhiteSpace(token))
|
|
{
|
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
|
}
|
|
}
|
|
|
|
private async Task<string?> GetAccessTokenAsync(string scope, CancellationToken cancellationToken)
|
|
{
|
|
if (tokenClient is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
lock (tokenSync)
|
|
{
|
|
if (cachedAccessToken is not null && DateTimeOffset.UtcNow < cachedAccessTokenExpiresAt - TokenRefreshSkew)
|
|
{
|
|
return cachedAccessToken;
|
|
}
|
|
}
|
|
|
|
var result = await tokenClient.GetTokenAsync(
|
|
new StellaOpsTokenRequest { Scopes = [scope] },
|
|
cancellationToken).ConfigureAwait(false);
|
|
|
|
if (result.IsSuccess)
|
|
{
|
|
lock (tokenSync)
|
|
{
|
|
cachedAccessToken = result.AccessToken;
|
|
cachedAccessTokenExpiresAt = result.ExpiresAt;
|
|
}
|
|
return result.AccessToken;
|
|
}
|
|
|
|
logger.LogWarning("Token acquisition failed: {Error}", result.Error);
|
|
return null;
|
|
}
|
|
}
|