up
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
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
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
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
This commit is contained in:
@@ -1,42 +1,42 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace StellaOps.Auth.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction for requesting tokens from StellaOps Authority.
|
||||
/// </summary>
|
||||
public interface IStellaOpsTokenClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Requests an access token using the resource owner password credentials flow.
|
||||
/// </summary>
|
||||
Task<StellaOpsTokenResult> RequestPasswordTokenAsync(string username, string password, string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Requests an access token using the client credentials flow.
|
||||
/// </summary>
|
||||
Task<StellaOpsTokenResult> RequestClientCredentialsTokenAsync(string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the cached JWKS document.
|
||||
/// </summary>
|
||||
Task<JsonWebKeySet> GetJsonWebKeySetAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a cached token entry.
|
||||
/// </summary>
|
||||
ValueTask<StellaOpsTokenCacheEntry?> GetCachedTokenAsync(string key, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Persists a token entry in the cache.
|
||||
/// </summary>
|
||||
ValueTask CacheTokenAsync(string key, StellaOpsTokenCacheEntry entry, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Removes a cached entry.
|
||||
/// </summary>
|
||||
ValueTask ClearCachedTokenAsync(string key, CancellationToken cancellationToken = default);
|
||||
}
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace StellaOps.Auth.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction for requesting tokens from StellaOps Authority.
|
||||
/// </summary>
|
||||
public interface IStellaOpsTokenClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Requests an access token using the resource owner password credentials flow.
|
||||
/// </summary>
|
||||
Task<StellaOpsTokenResult> RequestPasswordTokenAsync(string username, string password, string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Requests an access token using the client credentials flow.
|
||||
/// </summary>
|
||||
Task<StellaOpsTokenResult> RequestClientCredentialsTokenAsync(string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the cached JWKS document.
|
||||
/// </summary>
|
||||
Task<JsonWebKeySet> GetJsonWebKeySetAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a cached token entry.
|
||||
/// </summary>
|
||||
ValueTask<StellaOpsTokenCacheEntry?> GetCachedTokenAsync(string key, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Persists a token entry in the cache.
|
||||
/// </summary>
|
||||
ValueTask CacheTokenAsync(string key, StellaOpsTokenCacheEntry entry, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Removes a cached entry.
|
||||
/// </summary>
|
||||
ValueTask ClearCachedTokenAsync(string key, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -1,236 +1,236 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace StellaOps.Auth.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IStellaOpsTokenClient"/>.
|
||||
/// </summary>
|
||||
public sealed class StellaOpsTokenClient : IStellaOpsTokenClient
|
||||
{
|
||||
private static readonly MediaTypeHeaderValue JsonMediaType = new("application/json");
|
||||
|
||||
private readonly HttpClient httpClient;
|
||||
private readonly StellaOpsDiscoveryCache discoveryCache;
|
||||
private readonly StellaOpsJwksCache jwksCache;
|
||||
private readonly IOptionsMonitor<StellaOpsAuthClientOptions> optionsMonitor;
|
||||
private readonly IStellaOpsTokenCache tokenCache;
|
||||
private readonly TimeProvider timeProvider;
|
||||
private readonly ILogger<StellaOpsTokenClient>? logger;
|
||||
private readonly JsonSerializerOptions serializerOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public StellaOpsTokenClient(
|
||||
HttpClient httpClient,
|
||||
StellaOpsDiscoveryCache discoveryCache,
|
||||
StellaOpsJwksCache jwksCache,
|
||||
IOptionsMonitor<StellaOpsAuthClientOptions> optionsMonitor,
|
||||
IStellaOpsTokenCache tokenCache,
|
||||
TimeProvider? timeProvider = null,
|
||||
ILogger<StellaOpsTokenClient>? logger = null)
|
||||
{
|
||||
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
this.discoveryCache = discoveryCache ?? throw new ArgumentNullException(nameof(discoveryCache));
|
||||
this.jwksCache = jwksCache ?? throw new ArgumentNullException(nameof(jwksCache));
|
||||
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
|
||||
this.tokenCache = tokenCache ?? throw new ArgumentNullException(nameof(tokenCache));
|
||||
this.timeProvider = timeProvider ?? TimeProvider.System;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public Task<StellaOpsTokenResult> RequestPasswordTokenAsync(
|
||||
string username,
|
||||
string password,
|
||||
string? scope = null,
|
||||
IReadOnlyDictionary<string, string>? additionalParameters = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(username);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(password);
|
||||
|
||||
var options = optionsMonitor.CurrentValue;
|
||||
|
||||
var parameters = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["grant_type"] = "password",
|
||||
["username"] = username,
|
||||
["password"] = password,
|
||||
["client_id"] = options.ClientId
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(options.ClientSecret))
|
||||
{
|
||||
parameters["client_secret"] = options.ClientSecret;
|
||||
}
|
||||
|
||||
AppendScope(parameters, scope, options);
|
||||
|
||||
if (additionalParameters is not null)
|
||||
{
|
||||
foreach (var (key, value) in additionalParameters)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key) || value is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
parameters[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return RequestTokenAsync(parameters, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<StellaOpsTokenResult> RequestClientCredentialsTokenAsync(string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var options = optionsMonitor.CurrentValue;
|
||||
if (string.IsNullOrWhiteSpace(options.ClientId))
|
||||
{
|
||||
throw new InvalidOperationException("Client credentials flow requires ClientId to be configured.");
|
||||
}
|
||||
|
||||
var parameters = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["grant_type"] = "client_credentials",
|
||||
["client_id"] = options.ClientId
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(options.ClientSecret))
|
||||
{
|
||||
parameters["client_secret"] = options.ClientSecret;
|
||||
}
|
||||
|
||||
AppendScope(parameters, scope, options);
|
||||
|
||||
if (additionalParameters is not null)
|
||||
{
|
||||
foreach (var (key, value) in additionalParameters)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key) || value is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
parameters[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return RequestTokenAsync(parameters, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<JsonWebKeySet> GetJsonWebKeySetAsync(CancellationToken cancellationToken = default)
|
||||
=> jwksCache.GetAsync(cancellationToken);
|
||||
|
||||
public ValueTask<StellaOpsTokenCacheEntry?> GetCachedTokenAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> tokenCache.GetAsync(key, cancellationToken);
|
||||
|
||||
public ValueTask CacheTokenAsync(string key, StellaOpsTokenCacheEntry entry, CancellationToken cancellationToken = default)
|
||||
=> tokenCache.SetAsync(key, entry, cancellationToken);
|
||||
|
||||
public ValueTask ClearCachedTokenAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> tokenCache.RemoveAsync(key, cancellationToken);
|
||||
|
||||
private async Task<StellaOpsTokenResult> RequestTokenAsync(Dictionary<string, string> parameters, CancellationToken cancellationToken)
|
||||
{
|
||||
var options = optionsMonitor.CurrentValue;
|
||||
var configuration = await discoveryCache.GetAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, configuration.TokenEndpoint)
|
||||
{
|
||||
Content = new FormUrlEncodedContent(parameters)
|
||||
};
|
||||
request.Headers.Accept.TryParseAdd(JsonMediaType.ToString());
|
||||
|
||||
using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
logger?.LogWarning("Token request failed with status {StatusCode}: {Payload}", response.StatusCode, payload);
|
||||
throw new InvalidOperationException($"Token request failed with status {(int)response.StatusCode}.");
|
||||
}
|
||||
|
||||
var document = JsonSerializer.Deserialize<TokenResponseDocument>(payload, serializerOptions);
|
||||
if (document is null || string.IsNullOrWhiteSpace(document.AccessToken))
|
||||
{
|
||||
throw new InvalidOperationException("Token response did not contain an access_token.");
|
||||
}
|
||||
|
||||
var expiresIn = document.ExpiresIn ?? 3600;
|
||||
var expiresAt = timeProvider.GetUtcNow() + TimeSpan.FromSeconds(expiresIn);
|
||||
var normalizedScopes = ParseScopes(document.Scope ?? parameters.GetValueOrDefault("scope"));
|
||||
|
||||
var result = new StellaOpsTokenResult(
|
||||
document.AccessToken,
|
||||
document.TokenType ?? "Bearer",
|
||||
expiresAt,
|
||||
normalizedScopes,
|
||||
document.RefreshToken,
|
||||
document.IdToken,
|
||||
payload);
|
||||
|
||||
logger?.LogDebug("Token issued; expires at {ExpiresAt}.", expiresAt);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void AppendScope(IDictionary<string, string> parameters, string? scope, StellaOpsAuthClientOptions options)
|
||||
{
|
||||
var resolvedScope = scope;
|
||||
if (string.IsNullOrWhiteSpace(resolvedScope) && options.NormalizedScopes.Count > 0)
|
||||
{
|
||||
resolvedScope = string.Join(' ', options.NormalizedScopes);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(resolvedScope))
|
||||
{
|
||||
parameters["scope"] = resolvedScope;
|
||||
}
|
||||
}
|
||||
|
||||
private static string[] ParseScopes(string? scope)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(scope))
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var parts = scope.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (parts.Length == 0)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var unique = new HashSet<string>(parts.Length, StringComparer.Ordinal);
|
||||
foreach (var part in parts)
|
||||
{
|
||||
unique.Add(part);
|
||||
}
|
||||
|
||||
var result = new string[unique.Count];
|
||||
unique.CopyTo(result);
|
||||
Array.Sort(result, StringComparer.Ordinal);
|
||||
return result;
|
||||
}
|
||||
|
||||
private sealed record TokenResponseDocument(
|
||||
[property: JsonPropertyName("access_token")] string? AccessToken,
|
||||
[property: JsonPropertyName("refresh_token")] string? RefreshToken,
|
||||
[property: JsonPropertyName("id_token")] string? IdToken,
|
||||
[property: JsonPropertyName("token_type")] string? TokenType,
|
||||
[property: JsonPropertyName("expires_in")] int? ExpiresIn,
|
||||
[property: JsonPropertyName("scope")] string? Scope,
|
||||
[property: JsonPropertyName("error")] string? Error,
|
||||
[property: JsonPropertyName("error_description")] string? ErrorDescription);
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace StellaOps.Auth.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IStellaOpsTokenClient"/>.
|
||||
/// </summary>
|
||||
public sealed class StellaOpsTokenClient : IStellaOpsTokenClient
|
||||
{
|
||||
private static readonly MediaTypeHeaderValue JsonMediaType = new("application/json");
|
||||
|
||||
private readonly HttpClient httpClient;
|
||||
private readonly StellaOpsDiscoveryCache discoveryCache;
|
||||
private readonly StellaOpsJwksCache jwksCache;
|
||||
private readonly IOptionsMonitor<StellaOpsAuthClientOptions> optionsMonitor;
|
||||
private readonly IStellaOpsTokenCache tokenCache;
|
||||
private readonly TimeProvider timeProvider;
|
||||
private readonly ILogger<StellaOpsTokenClient>? logger;
|
||||
private readonly JsonSerializerOptions serializerOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public StellaOpsTokenClient(
|
||||
HttpClient httpClient,
|
||||
StellaOpsDiscoveryCache discoveryCache,
|
||||
StellaOpsJwksCache jwksCache,
|
||||
IOptionsMonitor<StellaOpsAuthClientOptions> optionsMonitor,
|
||||
IStellaOpsTokenCache tokenCache,
|
||||
TimeProvider? timeProvider = null,
|
||||
ILogger<StellaOpsTokenClient>? logger = null)
|
||||
{
|
||||
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
this.discoveryCache = discoveryCache ?? throw new ArgumentNullException(nameof(discoveryCache));
|
||||
this.jwksCache = jwksCache ?? throw new ArgumentNullException(nameof(jwksCache));
|
||||
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
|
||||
this.tokenCache = tokenCache ?? throw new ArgumentNullException(nameof(tokenCache));
|
||||
this.timeProvider = timeProvider ?? TimeProvider.System;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public Task<StellaOpsTokenResult> RequestPasswordTokenAsync(
|
||||
string username,
|
||||
string password,
|
||||
string? scope = null,
|
||||
IReadOnlyDictionary<string, string>? additionalParameters = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(username);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(password);
|
||||
|
||||
var options = optionsMonitor.CurrentValue;
|
||||
|
||||
var parameters = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["grant_type"] = "password",
|
||||
["username"] = username,
|
||||
["password"] = password,
|
||||
["client_id"] = options.ClientId
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(options.ClientSecret))
|
||||
{
|
||||
parameters["client_secret"] = options.ClientSecret;
|
||||
}
|
||||
|
||||
AppendScope(parameters, scope, options);
|
||||
|
||||
if (additionalParameters is not null)
|
||||
{
|
||||
foreach (var (key, value) in additionalParameters)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key) || value is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
parameters[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return RequestTokenAsync(parameters, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<StellaOpsTokenResult> RequestClientCredentialsTokenAsync(string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var options = optionsMonitor.CurrentValue;
|
||||
if (string.IsNullOrWhiteSpace(options.ClientId))
|
||||
{
|
||||
throw new InvalidOperationException("Client credentials flow requires ClientId to be configured.");
|
||||
}
|
||||
|
||||
var parameters = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["grant_type"] = "client_credentials",
|
||||
["client_id"] = options.ClientId
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(options.ClientSecret))
|
||||
{
|
||||
parameters["client_secret"] = options.ClientSecret;
|
||||
}
|
||||
|
||||
AppendScope(parameters, scope, options);
|
||||
|
||||
if (additionalParameters is not null)
|
||||
{
|
||||
foreach (var (key, value) in additionalParameters)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key) || value is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
parameters[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return RequestTokenAsync(parameters, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<JsonWebKeySet> GetJsonWebKeySetAsync(CancellationToken cancellationToken = default)
|
||||
=> jwksCache.GetAsync(cancellationToken);
|
||||
|
||||
public ValueTask<StellaOpsTokenCacheEntry?> GetCachedTokenAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> tokenCache.GetAsync(key, cancellationToken);
|
||||
|
||||
public ValueTask CacheTokenAsync(string key, StellaOpsTokenCacheEntry entry, CancellationToken cancellationToken = default)
|
||||
=> tokenCache.SetAsync(key, entry, cancellationToken);
|
||||
|
||||
public ValueTask ClearCachedTokenAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> tokenCache.RemoveAsync(key, cancellationToken);
|
||||
|
||||
private async Task<StellaOpsTokenResult> RequestTokenAsync(Dictionary<string, string> parameters, CancellationToken cancellationToken)
|
||||
{
|
||||
var options = optionsMonitor.CurrentValue;
|
||||
var configuration = await discoveryCache.GetAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, configuration.TokenEndpoint)
|
||||
{
|
||||
Content = new FormUrlEncodedContent(parameters)
|
||||
};
|
||||
request.Headers.Accept.TryParseAdd(JsonMediaType.ToString());
|
||||
|
||||
using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
logger?.LogWarning("Token request failed with status {StatusCode}: {Payload}", response.StatusCode, payload);
|
||||
throw new InvalidOperationException($"Token request failed with status {(int)response.StatusCode}.");
|
||||
}
|
||||
|
||||
var document = JsonSerializer.Deserialize<TokenResponseDocument>(payload, serializerOptions);
|
||||
if (document is null || string.IsNullOrWhiteSpace(document.AccessToken))
|
||||
{
|
||||
throw new InvalidOperationException("Token response did not contain an access_token.");
|
||||
}
|
||||
|
||||
var expiresIn = document.ExpiresIn ?? 3600;
|
||||
var expiresAt = timeProvider.GetUtcNow() + TimeSpan.FromSeconds(expiresIn);
|
||||
var normalizedScopes = ParseScopes(document.Scope ?? parameters.GetValueOrDefault("scope"));
|
||||
|
||||
var result = new StellaOpsTokenResult(
|
||||
document.AccessToken,
|
||||
document.TokenType ?? "Bearer",
|
||||
expiresAt,
|
||||
normalizedScopes,
|
||||
document.RefreshToken,
|
||||
document.IdToken,
|
||||
payload);
|
||||
|
||||
logger?.LogDebug("Token issued; expires at {ExpiresAt}.", expiresAt);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void AppendScope(IDictionary<string, string> parameters, string? scope, StellaOpsAuthClientOptions options)
|
||||
{
|
||||
var resolvedScope = scope;
|
||||
if (string.IsNullOrWhiteSpace(resolvedScope) && options.NormalizedScopes.Count > 0)
|
||||
{
|
||||
resolvedScope = string.Join(' ', options.NormalizedScopes);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(resolvedScope))
|
||||
{
|
||||
parameters["scope"] = resolvedScope;
|
||||
}
|
||||
}
|
||||
|
||||
private static string[] ParseScopes(string? scope)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(scope))
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var parts = scope.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (parts.Length == 0)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var unique = new HashSet<string>(parts.Length, StringComparer.Ordinal);
|
||||
foreach (var part in parts)
|
||||
{
|
||||
unique.Add(part);
|
||||
}
|
||||
|
||||
var result = new string[unique.Count];
|
||||
unique.CopyTo(result);
|
||||
Array.Sort(result, StringComparer.Ordinal);
|
||||
return result;
|
||||
}
|
||||
|
||||
private sealed record TokenResponseDocument(
|
||||
[property: JsonPropertyName("access_token")] string? AccessToken,
|
||||
[property: JsonPropertyName("refresh_token")] string? RefreshToken,
|
||||
[property: JsonPropertyName("id_token")] string? IdToken,
|
||||
[property: JsonPropertyName("token_type")] string? TokenType,
|
||||
[property: JsonPropertyName("expires_in")] int? ExpiresIn,
|
||||
[property: JsonPropertyName("scope")] string? Scope,
|
||||
[property: JsonPropertyName("error")] string? Error,
|
||||
[property: JsonPropertyName("error_description")] string? ErrorDescription);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user