diff --git a/src/Dexpace.Sdk.Core/Auth/AccessToken.cs b/src/Dexpace.Sdk.Core/Auth/AccessToken.cs new file mode 100644 index 0000000..1231a66 --- /dev/null +++ b/src/Dexpace.Sdk.Core/Auth/AccessToken.cs @@ -0,0 +1,53 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +namespace Dexpace.Sdk.Core.Auth; + +/// +/// An access token returned by a , together with its expiry +/// and an optional proactive-refresh hint. +/// +public readonly struct AccessToken +{ + /// + /// Initializes an with an expiry and no proactive-refresh hint. + /// + /// The raw token string. + /// The time at which this token becomes invalid. + /// is . + public AccessToken(string token, DateTimeOffset expiresOn) + : this(token, expiresOn, null) + { + } + + /// + /// Initializes an with an expiry and an optional proactive-refresh hint. + /// + /// The raw token string. + /// The time at which this token becomes invalid. + /// + /// An optional hint: when the clock reaches this value the token cache should proactively + /// refresh, even though has not been reached. + /// + /// is . + public AccessToken(string token, DateTimeOffset expiresOn, DateTimeOffset? refreshOn) + { + ArgumentNullException.ThrowIfNull(token); + Token = token; + ExpiresOn = expiresOn; + RefreshOn = refreshOn; + } + + /// The raw token value. + public string Token { get; } + + /// The time at which this token becomes invalid. + public DateTimeOffset ExpiresOn { get; } + + /// + /// An optional proactive-refresh hint. When non-, the token cache + /// begins refreshing once the clock passes this value, even though + /// has not yet been reached. + /// + public DateTimeOffset? RefreshOn { get; } +} diff --git a/src/Dexpace.Sdk.Core/Auth/AccessTokenCache.cs b/src/Dexpace.Sdk.Core/Auth/AccessTokenCache.cs new file mode 100644 index 0000000..b2a73af --- /dev/null +++ b/src/Dexpace.Sdk.Core/Auth/AccessTokenCache.cs @@ -0,0 +1,172 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +namespace Dexpace.Sdk.Core.Auth; + +/// +/// An in-memory token cache that wraps a and provides +/// proactive refresh, expiry-based invalidation, and single-flight protection against +/// concurrent refresh stampedes. +/// +/// +/// +/// Tokens are cached keyed by the . A cached token +/// is served without calling the underlying credential as long as both: +/// +/// now < ExpiresOn +/// RefreshOn is OR now < RefreshOn +/// +/// +/// +/// Once either condition fails, a single caller acquires the per-key semaphore and calls +/// the credential. All concurrent callers wait on the semaphore and perform a double-checked +/// read after acquiring it. Only one network round-trip fires per key per refresh cycle. +/// +/// +/// Failure while valid: if the credential throws but a token remains valid +/// (now < ExpiresOn), the cached token is returned silently. If no valid token +/// exists the exception propagates. +/// +/// +/// This class is thread-safe. Inject a custom for deterministic +/// unit testing. +/// +/// +public sealed class AccessTokenCache +{ + private readonly TokenCredential _credential; + private readonly TimeProvider _time; + + // Per-key state: the current cached token (null = never fetched) + a semaphore for + // single-flight. One CacheEntry per unique TokenRequestContext.CacheKey. + private readonly System.Collections.Concurrent.ConcurrentDictionary _entries = new(); + + /// + /// Initializes an backed by the given credential. + /// + /// The underlying credential to call when a token is needed. + /// + /// A used to determine "now". Defaults to + /// when . + /// + /// is . + public AccessTokenCache(TokenCredential credential, TimeProvider? timeProvider = null) + { + ArgumentNullException.ThrowIfNull(credential); + _credential = credential; + _time = timeProvider ?? TimeProvider.System; + } + + /// + /// Returns a valid for the given , + /// fetching and caching one if necessary. + /// + /// The token request context identifying the scopes and claims. + /// A token to cancel an in-progress credential call. + /// + /// A resolving to a valid access token. + /// + public async ValueTask GetAsync(TokenRequestContext context, CancellationToken ct = default) + { + var entry = _entries.GetOrAdd(context.CacheKey, static _ => new CacheEntry()); + var now = _time.GetUtcNow(); + + // Fast path: read the holder once (volatile acquire) and return immediately when the + // token is still valid and does not need a proactive refresh. The volatile read ensures + // that any holder published by a slow-path writer is fully visible to this thread. + var fastHolder = entry.Holder; + if (fastHolder is not null && IsValid(fastHolder.Token, now) && !NeedsRefresh(fastHolder.Token, now)) + { + return fastHolder.Token; + } + + // Slow path: acquire the semaphore for this key. + await entry.Semaphore.WaitAsync(ct).ConfigureAwait(false); + try + { + // Double-check after acquiring. + now = _time.GetUtcNow(); + var recheckedHolder = entry.Holder; + if (recheckedHolder is not null && IsValid(recheckedHolder.Token, now) && !NeedsRefresh(recheckedHolder.Token, now)) + { + return recheckedHolder.Token; + } + + // Try to refresh from the credential. + try + { + var fresh = await _credential.GetTokenAsync(context, ct).ConfigureAwait(false); + // Volatile write releases the fully-constructed holder to all threads. + entry.Holder = new TokenHolder(fresh); + return fresh; + } + catch + { + // Re-sample time: GetTokenAsync may have taken a long time. Validate the + // cached token against the clock *after* the (slow) failing call, so a token + // that expired during the attempt is not returned as valid. + var nowAfterFailure = _time.GetUtcNow(); + var fallbackHolder = entry.Holder; + if (fallbackHolder is not null && IsValid(fallbackHolder.Token, nowAfterFailure)) + { + return fallbackHolder.Token; + } + + throw; + } + } + finally + { + entry.Semaphore.Release(); + } + } + + // A token is "valid" while the clock hasn't yet reached ExpiresOn. + private static bool IsValid(AccessToken token, DateTimeOffset now) => + now < token.ExpiresOn; + + // A token "needs refresh" once RefreshOn has been reached (proactive hint). + private static bool NeedsRefresh(AccessToken token, DateTimeOffset now) => + token.RefreshOn is { } refreshOn && now >= refreshOn; + + // Immutable wrapper so the token can be published through a single volatile reference, + // giving acquire/release ordering on all platforms. AccessToken is a multi-field struct; + // publishing it directly would allow fast-path readers to observe a torn or partially + // written value. Boxing it inside a reference type (TokenHolder) reduces the published + // value to a single pointer-width write, which the .NET memory model guarantees to be + // atomic and not torn. + private sealed class TokenHolder + { + /// Initializes a wrapping the given token. + /// The token to publish atomically. + public TokenHolder(AccessToken token) => Token = token; + + /// The wrapped access token. + public AccessToken Token { get; } + } + + private sealed class CacheEntry + { + // volatile ensures that a fast-path reader always observes the most recently published + // holder (acquire semantics on read, release semantics on write). Combined with the + // pointer-width atomicity of reference reads/writes, this is safe without a lock on + // the fast path. + private volatile TokenHolder? _holder; + + /// + /// The most recently published token holder, or if no token + /// has been fetched yet. Reads and writes are reference-atomic and carry + /// acquire/release ordering via the modifier. + /// + public TokenHolder? Holder + { + get => _holder; + set => _holder = value; + } + + /// + /// A semaphore (initial count = 1) that serializes refresh calls for this key. + /// + public SemaphoreSlim Semaphore { get; } = new SemaphoreSlim(1, 1); + } +} diff --git a/src/Dexpace.Sdk.Core/Auth/ApiKeyCredential.cs b/src/Dexpace.Sdk.Core/Auth/ApiKeyCredential.cs new file mode 100644 index 0000000..9d2051a --- /dev/null +++ b/src/Dexpace.Sdk.Core/Auth/ApiKeyCredential.cs @@ -0,0 +1,56 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Http.Common; + +namespace Dexpace.Sdk.Core.Auth; + +/// +/// A credential that authenticates requests by stamping a static API key into an HTTP header. +/// +/// +/// By default the key is sent in the Authorization header with no scheme prefix, i.e. +/// the header value is exactly . Specify a scheme to add a prefix, e.g. +/// "Bearer" produces Authorization: Bearer <key>. Pass a custom header +/// name to use a non-standard header such as X-Api-Key. +/// +public sealed class ApiKeyCredential +{ + /// + /// Initializes an . + /// + /// The API key value. Must not be null or empty. + /// + /// The header to stamp. Defaults to . + /// + /// + /// An optional scheme prefix (e.g. "Bearer"). When the key + /// is used as the entire header value. + /// + /// is . + /// is empty. + public ApiKeyCredential(string key, HttpHeaderName? header = null, string? scheme = null) + { + ArgumentNullException.ThrowIfNull(key); + if (key.Length == 0) + { + throw new ArgumentException("API key must not be empty.", nameof(key)); + } + + Key = key; + HeaderName = header ?? HttpHeaderName.WellKnown.Authorization; + Scheme = scheme; + } + + /// The raw API key value. + public string Key { get; } + + /// The HTTP header into which the key is stamped. + public HttpHeaderName HeaderName { get; } + + /// + /// The optional scheme prefix. When non-, the header value is + /// "<Scheme> <Key>"; otherwise the header value is exactly . + /// + public string? Scheme { get; } +} diff --git a/src/Dexpace.Sdk.Core/Auth/BasicCredential.cs b/src/Dexpace.Sdk.Core/Auth/BasicCredential.cs new file mode 100644 index 0000000..c60060b --- /dev/null +++ b/src/Dexpace.Sdk.Core/Auth/BasicCredential.cs @@ -0,0 +1,50 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Text; + +namespace Dexpace.Sdk.Core.Auth; + +/// +/// A credential that authenticates requests using the HTTP Basic scheme (RFC 7617). +/// +/// +/// The credential stores the username and password in plain text. Call +/// to obtain the Base64-encoded username:password token suitable for the +/// Authorization: Basic <token> header value. +/// +public sealed class BasicCredential +{ + /// + /// Initializes a with the given username and password. + /// + /// The username. Must not be . + /// The password. Must not be . + /// + /// or is . + /// + public BasicCredential(string username, string password) + { + ArgumentNullException.ThrowIfNull(username); + ArgumentNullException.ThrowIfNull(password); + Username = username; + Password = password; + } + + /// The username. + public string Username { get; } + + /// The password. + public string Password { get; } + + /// + /// Returns the Base64-encoded UTF-8 username:password token for use in the + /// Authorization: Basic <token> header value. + /// + /// The Base64-encoded credentials. + public string ToBase64() + { + var bytes = Encoding.UTF8.GetBytes($"{Username}:{Password}"); + return Convert.ToBase64String(bytes); + } +} diff --git a/src/Dexpace.Sdk.Core/Auth/TokenCredential.cs b/src/Dexpace.Sdk.Core/Auth/TokenCredential.cs new file mode 100644 index 0000000..013a908 --- /dev/null +++ b/src/Dexpace.Sdk.Core/Auth/TokenCredential.cs @@ -0,0 +1,48 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +namespace Dexpace.Sdk.Core.Auth; + +/// +/// Base class for credential implementations that produce instances. +/// +/// +/// +/// Subclasses implement and may optionally override +/// for a non-blocking synchronous path. The default +/// implementation is a blocking bridge over +/// ; override it when a truly synchronous code path exists. +/// +/// +/// Tokens are typically obtained through an AccessTokenCache rather than calling +/// this type directly, so that caching, proactive refresh, and single-flight behaviour are +/// applied automatically. +/// +/// +public abstract class TokenCredential +{ + /// + /// Asynchronously obtains an for the requested context. + /// + /// The scopes and optional claims for the token request. + /// A token to cancel the request. + /// + /// A that resolves to the token on success. + /// + public abstract ValueTask GetTokenAsync( + TokenRequestContext context, + CancellationToken ct = default); + + /// + /// Synchronously obtains an for the requested context. + /// + /// + /// The default implementation is a blocking bridge over . + /// Override this method when a non-blocking synchronous path is available. + /// + /// The scopes and optional claims for the token request. + /// A token to cancel the request. + /// The access token. + public virtual AccessToken GetToken(TokenRequestContext context, CancellationToken ct = default) + => GetTokenAsync(context, ct).AsTask().GetAwaiter().GetResult(); +} diff --git a/src/Dexpace.Sdk.Core/Auth/TokenRequestContext.cs b/src/Dexpace.Sdk.Core/Auth/TokenRequestContext.cs new file mode 100644 index 0000000..27901fc --- /dev/null +++ b/src/Dexpace.Sdk.Core/Auth/TokenRequestContext.cs @@ -0,0 +1,77 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Text; + +namespace Dexpace.Sdk.Core.Auth; + +/// +/// Describes the parameters of a token request: the OAuth 2.0 scopes required and an optional +/// claims challenge string. +/// +/// +/// The shape intentionally mirrors Azure.Core.TokenRequestContext to ease the +/// Dexpace.Sdk.Auth.AzureIdentity adapter. +/// +public readonly struct TokenRequestContext +{ + /// + /// Initializes a with the specified scopes and no claims. + /// + /// The OAuth 2.0 scopes for which a token is required. + /// is . + public TokenRequestContext(IReadOnlyList scopes) + : this(scopes, null) + { + } + + /// + /// Initializes a with the specified scopes and claims. + /// + /// The OAuth 2.0 scopes for which a token is required. + /// + /// An optional claims challenge returned by the resource in a previous 401 response. + /// + /// is . + public TokenRequestContext(IReadOnlyList scopes, string? claims) + { + ArgumentNullException.ThrowIfNull(scopes); + Scopes = scopes; + Claims = claims; + CacheKey = BuildCacheKey(scopes, claims); + } + + /// The OAuth 2.0 scopes for which a token is required. + public IReadOnlyList Scopes { get; } + + /// An optional claims challenge to include in the token request. + public string? Claims { get; } + + /// + /// A stable string key derived from and . + /// Suitable for use as a dictionary key in a token cache. + /// + public string CacheKey { get; } + + private static string BuildCacheKey(IReadOnlyList scopes, string? claims) + { + var sb = new StringBuilder(); + for (var i = 0; i < scopes.Count; i++) + { + if (i > 0) + { + sb.Append(' '); + } + + sb.Append(scopes[i]); + } + + if (claims is not null) + { + sb.Append('|'); + sb.Append(claims); + } + + return sb.ToString(); + } +} diff --git a/src/Dexpace.Sdk.Core/Pipeline/Policies/ApiKeyAuthPolicy.cs b/src/Dexpace.Sdk.Core/Pipeline/Policies/ApiKeyAuthPolicy.cs new file mode 100644 index 0000000..30129cd --- /dev/null +++ b/src/Dexpace.Sdk.Core/Pipeline/Policies/ApiKeyAuthPolicy.cs @@ -0,0 +1,62 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Auth; +using Dexpace.Sdk.Core.Http.Common; + +namespace Dexpace.Sdk.Core.Pipeline.Policies; + +/// +/// An auth-stage pipeline policy that stamps an API-key credential header on every outgoing +/// request, replacing any prior value for that header. +/// +/// +/// +/// The header and optional scheme prefix are taken directly from the +/// supplied at construction time: +/// +/// +/// +/// When is the header value is +/// exactly . +/// +/// +/// When is non- the header value +/// is "<Scheme> <Key>" (e.g. "Bearer sk-abc123"). +/// +/// +/// +/// Credentials are withheld when the request has been redirected to a different origin; see +/// for the cross-origin withholding contract. +/// +/// +public sealed class ApiKeyAuthPolicy : AuthorizationPolicy +{ + private readonly ApiKeyCredential _credential; + + /// + /// Initializes an with the given credential. + /// + /// The API-key credential to stamp on every same-origin request. + /// is . + public ApiKeyAuthPolicy(ApiKeyCredential credential) + { + ArgumentNullException.ThrowIfNull(credential); + _credential = credential; + } + + /// + protected override HttpHeaderName WithheldHeaderName => _credential.HeaderName; + + /// + protected override ValueTask<(string HeaderName, string HeaderValue)> GetCredentialAsync( + PipelineContext context) + { + var headerName = _credential.HeaderName.Original; + var headerValue = _credential.Scheme is null + ? _credential.Key + : $"{_credential.Scheme} {_credential.Key}"; + + return new ValueTask<(string, string)>((headerName, headerValue)); + } +} diff --git a/src/Dexpace.Sdk.Core/Pipeline/Policies/AuthorizationPolicy.cs b/src/Dexpace.Sdk.Core/Pipeline/Policies/AuthorizationPolicy.cs new file mode 100644 index 0000000..acde414 --- /dev/null +++ b/src/Dexpace.Sdk.Core/Pipeline/Policies/AuthorizationPolicy.cs @@ -0,0 +1,111 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Http.Common; + +namespace Dexpace.Sdk.Core.Pipeline.Policies; + +/// +/// Abstract base class for all auth policies placed at . +/// +/// +/// +/// This base class implements the cross-origin withholding contract: credentials are stamped only +/// when the current request's origin (scheme + host + port) matches the origin of the first +/// invocation. If a redirect has moved the request to a different origin, the credential header is +/// actively removed from the request before the continuation is called — providing defense-in-depth +/// independent of . A consumer who composes an auth policy without +/// , or who sets StripSensitiveHeadersOnCrossOrigin=false, +/// cannot accidentally forward a stale credential to a foreign origin. +/// +/// +/// The recorded origin is stored in 's property bag under the key +/// "dexpace.auth.origin". On the very first invocation for a given context, the key is +/// absent: the base class records the current origin and proceeds to stamp. On each subsequent +/// invocation (redirect loop, retry) the base class compares the current origin to the recorded +/// one before stamping. +/// +/// +/// Derived classes must implement to supply the header name +/// and value to stamp, and to identify the header to remove on a +/// cross-origin hop. The base class performs the Headers.Set / Headers.Without +/// writes and the continuation.RunAsync call. is +/// accessed only on the cross-origin branch; it must not trigger credential resolution (e.g. a +/// token-cache lookup). +/// +/// +public abstract class AuthorizationPolicy : HttpPipelinePolicy +{ + // Property-bag key used to record the origin seen on the first invocation. + private const string OriginKey = "dexpace.auth.origin"; + + /// + public sealed override PipelineStage Stage => PipelineStage.Auth; + + /// + /// The name of the HTTP header that this policy stamps on same-origin requests. + /// On a cross-origin hop the base class removes this header from the outgoing request + /// before calling the continuation — no credential resolution is performed. + /// + protected abstract HttpHeaderName WithheldHeaderName { get; } + + /// + public sealed override async ValueTask ProcessAsync(PipelineContext context, PipelineRunner continuation) + { + ArgumentNullException.ThrowIfNull(context); + + var currentOrigin = GetOrigin(context.Request.Url); + + var recordedOrigin = context.GetProperty(OriginKey); + if (recordedOrigin is null) + { + // First invocation for this context: record the origin, then stamp. + context.SetProperty(OriginKey, currentOrigin); + } + else if (!string.Equals(recordedOrigin, currentOrigin, StringComparison.OrdinalIgnoreCase)) + { + // Request has been redirected to a different origin — strip the credential header + // (defense-in-depth: removes any stale value carried over from the original request) + // and forward the request without credential. + context.Request = context.Request with + { + Headers = context.Request.Headers.Without(WithheldHeaderName.Original) + }; + + await continuation.RunAsync(context).ConfigureAwait(false); + return; + } + + var (headerName, headerValue) = await GetCredentialAsync(context).ConfigureAwait(false); + + context.Request = context.Request with + { + Headers = context.Request.Headers.Set(headerName, headerValue) + }; + + await continuation.RunAsync(context).ConfigureAwait(false); + } + + /// + /// Returns the header name (as a string suitable for ) + /// and the header value to stamp on the outgoing request. + /// + /// The current pipeline context. + /// + /// A that resolves to a (headerName, headerValue) pair. + /// + protected abstract ValueTask<(string HeaderName, string HeaderValue)> GetCredentialAsync( + PipelineContext context); + + // Derives a canonical origin string: "://:". + // Port is always included — Uri.Port returns -1 for the default scheme port, + // so same-origin comparisons are consistent regardless of whether the caller + // supplied the port explicitly. + private static string GetOrigin(Uri uri) + { + var scheme = uri.Scheme.ToLowerInvariant(); + var host = uri.Host.ToLowerInvariant(); + var port = uri.Port; // -1 means default for scheme + return $"{scheme}://{host}:{port}"; + } +} diff --git a/src/Dexpace.Sdk.Core/Pipeline/Policies/BasicAuthPolicy.cs b/src/Dexpace.Sdk.Core/Pipeline/Policies/BasicAuthPolicy.cs new file mode 100644 index 0000000..05e74f0 --- /dev/null +++ b/src/Dexpace.Sdk.Core/Pipeline/Policies/BasicAuthPolicy.cs @@ -0,0 +1,50 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Auth; +using Dexpace.Sdk.Core.Http.Common; + +namespace Dexpace.Sdk.Core.Pipeline.Policies; + +/// +/// An auth-stage pipeline policy that stamps HTTP Basic authentication (RFC 7617) on every +/// outgoing request, replacing any prior Authorization header value. +/// +/// +/// +/// The header value is "Basic <base64(username:password)>" where the token is the +/// UTF-8 Base64 encoding returned by . +/// +/// +/// Credentials are withheld when the request has been redirected to a different origin; see +/// for the cross-origin withholding contract. +/// +/// +public sealed class BasicAuthPolicy : AuthorizationPolicy +{ + // Pre-compute the header value once — BasicCredential is immutable, so the Base64 token + // never changes during the lifetime of this policy instance. + private readonly string _headerValue; + + /// + /// Initializes a with the given credential. + /// + /// The Basic credential to stamp on every same-origin request. + /// is . + public BasicAuthPolicy(BasicCredential credential) + { + ArgumentNullException.ThrowIfNull(credential); + _headerValue = $"Basic {credential.ToBase64()}"; + } + + /// + protected override HttpHeaderName WithheldHeaderName => HttpHeaderName.WellKnown.Authorization; + + /// + protected override ValueTask<(string HeaderName, string HeaderValue)> GetCredentialAsync( + PipelineContext context) + { + return new ValueTask<(string, string)>( + (HttpHeaderName.WellKnown.Authorization.Original, _headerValue)); + } +} diff --git a/src/Dexpace.Sdk.Core/Pipeline/Policies/BearerTokenAuthPolicy.cs b/src/Dexpace.Sdk.Core/Pipeline/Policies/BearerTokenAuthPolicy.cs new file mode 100644 index 0000000..5a69674 --- /dev/null +++ b/src/Dexpace.Sdk.Core/Pipeline/Policies/BearerTokenAuthPolicy.cs @@ -0,0 +1,79 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Auth; +using Dexpace.Sdk.Core.Http.Common; + +namespace Dexpace.Sdk.Core.Pipeline.Policies; + +/// +/// An auth-stage pipeline policy that obtains an OAuth 2.0 bearer token via an +/// and stamps Authorization: Bearer <token> on every +/// outgoing request, replacing any prior Authorization header value. +/// +/// +/// +/// The policy creates exactly one for the lifetime of the policy +/// instance, wrapping the supplied . All pipeline invocations share +/// that cache, so concurrent or sequential requests reuse a valid cached token and avoid redundant +/// credential calls. +/// +/// +/// Token acquisition is always async; the policy calls +/// using the +/// so that request cancellation propagates into +/// token fetching. +/// +/// +/// 401 re-acquisition: re-acquiring a token on a 401 challenge response is deferred +/// to the challenge-handling work (to arrive with ChallengeHandler). This policy performs +/// a single token-get per request. +/// +/// +/// Credentials are withheld when the request has been redirected to a different origin; see +/// for the cross-origin withholding contract. +/// +/// +public sealed class BearerTokenAuthPolicy : AuthorizationPolicy +{ + private readonly AccessTokenCache _cache; + private readonly TokenRequestContext _tokenRequestContext; + + /// + /// Initializes a with the given credential and scopes. + /// + /// + /// The token credential used to obtain bearer tokens. A single + /// is created over this credential and shared across all + /// requests. + /// + /// + /// The OAuth 2.0 scopes to request. Passed to + /// via . + /// + /// + /// or is . + /// + public BearerTokenAuthPolicy(TokenCredential credential, params string[] scopes) + { + ArgumentNullException.ThrowIfNull(credential); + ArgumentNullException.ThrowIfNull(scopes); + + _cache = new AccessTokenCache(credential); + _tokenRequestContext = new TokenRequestContext(scopes); + } + + /// + protected override HttpHeaderName WithheldHeaderName => HttpHeaderName.WellKnown.Authorization; + + /// + protected override async ValueTask<(string HeaderName, string HeaderValue)> GetCredentialAsync( + PipelineContext context) + { + var token = await _cache + .GetAsync(_tokenRequestContext, context.CancellationToken) + .ConfigureAwait(false); + + return (HttpHeaderName.WellKnown.Authorization.Original, $"Bearer {token.Token}"); + } +} diff --git a/tests/Dexpace.Sdk.Core.Tests/Auth/AccessTokenCacheTests.cs b/tests/Dexpace.Sdk.Core.Tests/Auth/AccessTokenCacheTests.cs new file mode 100644 index 0000000..5519fdc --- /dev/null +++ b/tests/Dexpace.Sdk.Core.Tests/Auth/AccessTokenCacheTests.cs @@ -0,0 +1,314 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Auth; +using Xunit; + +namespace Dexpace.Sdk.Core.Tests.Auth; + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +/// +/// A fake TokenCredential that counts calls and returns configurable tokens. +/// Thread-safe via Interlocked. +/// +file sealed class FakeTokenCredential : TokenCredential +{ + private int _callCount; + private Func _factory; + + public int CallCount => _callCount; + + public FakeTokenCredential(Func factory) + => _factory = factory; + + /// Replace the factory (used to inject failures between calls). + public void SetFactory(Func factory) => _factory = factory; + + public override ValueTask GetTokenAsync(TokenRequestContext context, CancellationToken ct = default) + { + Interlocked.Increment(ref _callCount); + return ValueTask.FromResult(_factory(context)); + } +} + +/// +/// A fake TokenCredential that throws on demand. +/// +file sealed class ThrowingTokenCredential : TokenCredential +{ + private readonly Exception _ex; + + public ThrowingTokenCredential(Exception ex) => _ex = ex; + + public override ValueTask GetTokenAsync(TokenRequestContext context, CancellationToken ct = default) + => throw _ex; +} + +/// +/// A controllable TimeProvider for deterministic time tests. +/// +file sealed class ManualTimeProvider : TimeProvider +{ + private DateTimeOffset _now; + + public ManualTimeProvider(DateTimeOffset initial) => _now = initial; + + public override DateTimeOffset GetUtcNow() => _now; + + public void Advance(TimeSpan delta) => _now = _now.Add(delta); + + public void SetUtcNow(DateTimeOffset value) => _now = value; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +public class AccessTokenCacheTests +{ + private static TokenRequestContext Ctx(string scope = "scope") => + new TokenRequestContext([scope]); + + private static DateTimeOffset Now() => DateTimeOffset.UtcNow; + + // ----------------------------------------------------------------------- + // 1. Repeated GetAsync within validity calls the credential ONCE + // ----------------------------------------------------------------------- + + [Fact] + public async Task GetAsync_WithinValidity_ReturnsTokenWithoutRefetch() + { + var time = new ManualTimeProvider(Now()); + var expected = new AccessToken("tok", time.GetUtcNow().AddHours(1)); + var cred = new FakeTokenCredential(_ => expected); + var cache = new AccessTokenCache(cred, time); + var ctx = Ctx(); + + var t1 = await cache.GetAsync(ctx); + var t2 = await cache.GetAsync(ctx); + var t3 = await cache.GetAsync(ctx); + + Assert.Equal(expected.Token, t1.Token); + Assert.Equal(expected.Token, t2.Token); + Assert.Equal(expected.Token, t3.Token); + Assert.Equal(1, cred.CallCount); + } + + // ----------------------------------------------------------------------- + // 2. Advancing past RefreshOn triggers exactly one refresh + // ----------------------------------------------------------------------- + + [Fact] + public async Task GetAsync_PastRefreshOn_RefreshesOnce() + { + var start = Now(); + var time = new ManualTimeProvider(start); + + // First token: expires in 1h, refresh hint at 50m + var firstToken = new AccessToken("first", start.AddHours(1), start.AddMinutes(50)); + var secondToken = new AccessToken("second", start.AddHours(2)); + var callCount = 0; + var cred = new FakeTokenCredential(_ => + { + callCount++; + return callCount == 1 ? firstToken : secondToken; + }); + + var cache = new AccessTokenCache(cred, time); + var ctx = Ctx(); + + // Call within validity window — gets first token + var t1 = await cache.GetAsync(ctx); + Assert.Equal("first", t1.Token); + Assert.Equal(1, callCount); + + // Advance past RefreshOn but still before ExpiresOn + time.Advance(TimeSpan.FromMinutes(51)); + + var t2 = await cache.GetAsync(ctx); + Assert.Equal("second", t2.Token); + Assert.Equal(2, callCount); + + // Further calls still use refreshed token — no additional fetches + var t3 = await cache.GetAsync(ctx); + Assert.Equal("second", t3.Token); + Assert.Equal(2, callCount); + } + + // ----------------------------------------------------------------------- + // 3. Advancing past ExpiresOn triggers a refresh + // ----------------------------------------------------------------------- + + [Fact] + public async Task GetAsync_PastExpiresOn_RefreshesToken() + { + var start = Now(); + var time = new ManualTimeProvider(start); + + var firstToken = new AccessToken("expired", start.AddMinutes(10)); + var secondToken = new AccessToken("fresh", start.AddHours(2)); + var callCount = 0; + var cred = new FakeTokenCredential(_ => + { + callCount++; + return callCount == 1 ? firstToken : secondToken; + }); + + var cache = new AccessTokenCache(cred, time); + var ctx = Ctx(); + + var t1 = await cache.GetAsync(ctx); + Assert.Equal("expired", t1.Token); + + // Jump past ExpiresOn + time.Advance(TimeSpan.FromMinutes(11)); + + var t2 = await cache.GetAsync(ctx); + Assert.Equal("fresh", t2.Token); + Assert.Equal(2, callCount); + } + + // ----------------------------------------------------------------------- + // 4. Concurrent GetAsync calls result in a single credential call (single-flight) + // ----------------------------------------------------------------------- + + [Fact] + public async Task GetAsync_Concurrent_SingleFlightRefresh() + { + var time = new ManualTimeProvider(Now()); + + // Use a TaskCompletionSource to make the credential artificially slow so + // all concurrent callers arrive before any returns. + var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var slowCred = new SlowTokenCredential(gate); + var cache = new AccessTokenCache(slowCred, time); + var ctx = Ctx(); + + // Start 50 concurrent requests before any token is available + var tasks = Enumerable.Range(0, 50) + .Select(_ => cache.GetAsync(ctx).AsTask()) + .ToArray(); + + // Let the credential complete + var token = new AccessToken("concurrent_tok", time.GetUtcNow().AddHours(1)); + gate.SetResult(token); + + var results = await Task.WhenAll(tasks); + + // All callers get the same token + Assert.All(results, r => Assert.Equal("concurrent_tok", r.Token)); + // Credential was called exactly once + Assert.Equal(1, slowCred.CallCount); + } + + // ----------------------------------------------------------------------- + // 5. Refresh that throws while valid returns cached token + // ----------------------------------------------------------------------- + + [Fact] + public async Task GetAsync_RefreshThrows_WhileStillValid_ReturnsCachedToken() + { + var start = Now(); + var time = new ManualTimeProvider(start); + + // Token: expires in 1h, refresh hint at 50m + var validToken = new AccessToken("valid", start.AddHours(1), start.AddMinutes(50)); + var callCount = 0; + var cred = new FakeTokenCredential(_ => + { + callCount++; + if (callCount == 1) + { + return validToken; + } + + throw new InvalidOperationException("Credential failure"); + }); + + var cache = new AccessTokenCache(cred, time); + var ctx = Ctx(); + + // Populate cache + var t1 = await cache.GetAsync(ctx); + Assert.Equal("valid", t1.Token); + + // Advance past RefreshOn — triggers a refresh attempt + time.Advance(TimeSpan.FromMinutes(51)); + + // Despite the throw, should return the still-valid cached token + var t2 = await cache.GetAsync(ctx); + Assert.Equal("valid", t2.Token); + + // Credential was called for the failed refresh + Assert.Equal(2, callCount); + } + + // ----------------------------------------------------------------------- + // 6. Refresh that throws with no valid token propagates + // ----------------------------------------------------------------------- + + [Fact] + public async Task GetAsync_RefreshThrows_WithNoValidToken_Propagates() + { + var time = new ManualTimeProvider(Now()); + var ex = new InvalidOperationException("no token"); + var cred = new ThrowingTokenCredential(ex); + var cache = new AccessTokenCache(cred, time); + + var thrown = await Assert.ThrowsAsync(() => + cache.GetAsync(Ctx()).AsTask()); + + Assert.Same(ex, thrown); + } + + // ----------------------------------------------------------------------- + // 7. Different contexts are cached independently + // ----------------------------------------------------------------------- + + [Fact] + public async Task GetAsync_DifferentContexts_CachedIndependently() + { + var time = new ManualTimeProvider(Now()); + var callCount = 0; + var cred = new FakeTokenCredential(ctx => + { + callCount++; + return new AccessToken($"tok-{ctx.Scopes[0]}", time.GetUtcNow().AddHours(1)); + }); + + var cache = new AccessTokenCache(cred, time); + + var t1 = await cache.GetAsync(Ctx("scope1")); + var t2 = await cache.GetAsync(Ctx("scope2")); + var t3 = await cache.GetAsync(Ctx("scope1")); // should hit cache + + Assert.Equal("tok-scope1", t1.Token); + Assert.Equal("tok-scope2", t2.Token); + Assert.Equal("tok-scope1", t3.Token); + // scope1 fetched once, scope2 fetched once = 2 calls + Assert.Equal(2, callCount); + } +} + +// Helper that cannot easily be expressed as a lambda: + +file sealed class SlowTokenCredential : TokenCredential +{ + private readonly TaskCompletionSource _gate; + private int _callCount; + + public int CallCount => _callCount; + + public SlowTokenCredential(TaskCompletionSource gate) + => _gate = gate; + + public override async ValueTask GetTokenAsync(TokenRequestContext context, CancellationToken ct = default) + { + Interlocked.Increment(ref _callCount); + return await _gate.Task.ConfigureAwait(false); + } +} diff --git a/tests/Dexpace.Sdk.Core.Tests/Auth/CredentialTypesTests.cs b/tests/Dexpace.Sdk.Core.Tests/Auth/CredentialTypesTests.cs new file mode 100644 index 0000000..1a82ad6 --- /dev/null +++ b/tests/Dexpace.Sdk.Core.Tests/Auth/CredentialTypesTests.cs @@ -0,0 +1,222 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Auth; +using Dexpace.Sdk.Core.Http.Common; +using Xunit; + +namespace Dexpace.Sdk.Core.Tests.Auth; + +public class AccessTokenTests +{ + [Fact] + public void Ctor_SetsAllProperties() + { + var token = "tok_abc"; + var expires = DateTimeOffset.UtcNow.AddHours(1); + var refresh = DateTimeOffset.UtcNow.AddMinutes(50); + + var at = new AccessToken(token, expires, refresh); + + Assert.Equal(token, at.Token); + Assert.Equal(expires, at.ExpiresOn); + Assert.Equal(refresh, at.RefreshOn); + } + + [Fact] + public void Ctor_NullableRefreshOn_IsNull_WhenOmitted() + { + var expires = DateTimeOffset.UtcNow.AddHours(1); + var at = new AccessToken("tok", expires); + + Assert.Null(at.RefreshOn); + } + + [Fact] + public void Ctor_ThrowsOnNullToken() + { + Assert.Throws(() => new AccessToken(null!, DateTimeOffset.UtcNow)); + } +} + +public class TokenRequestContextTests +{ + [Fact] + public void Ctor_SetsScopes() + { + IReadOnlyList scopes = ["https://example.com/.default"]; + var ctx = new TokenRequestContext(scopes); + + Assert.Same(scopes, ctx.Scopes); + Assert.Null(ctx.Claims); + } + + [Fact] + public void Ctor_SetsScopesAndClaims() + { + IReadOnlyList scopes = ["scope1", "scope2"]; + var ctx = new TokenRequestContext(scopes, "some-claims"); + + Assert.Equal(scopes, ctx.Scopes); + Assert.Equal("some-claims", ctx.Claims); + } + + [Fact] + public void CacheKey_IsSameForSameInputs() + { + IReadOnlyList scopes = ["a", "b"]; + var ctx1 = new TokenRequestContext(scopes, "claims"); + var ctx2 = new TokenRequestContext(scopes, "claims"); + + Assert.Equal(ctx1.CacheKey, ctx2.CacheKey); + } + + [Fact] + public void CacheKey_DiffersForDifferentScopes() + { + var ctx1 = new TokenRequestContext(["scopeA"]); + var ctx2 = new TokenRequestContext(["scopeB"]); + + Assert.NotEqual(ctx1.CacheKey, ctx2.CacheKey); + } + + [Fact] + public void CacheKey_DiffersWhenClaimsChange() + { + IReadOnlyList scopes = ["s"]; + var ctx1 = new TokenRequestContext(scopes); + var ctx2 = new TokenRequestContext(scopes, "extra"); + + Assert.NotEqual(ctx1.CacheKey, ctx2.CacheKey); + } + + [Fact] + public void CacheKey_StableForSameScopesInSameOrder() + { + var ctx1 = new TokenRequestContext(["x", "y"]); + var ctx2 = new TokenRequestContext(["x", "y"]); + + Assert.Equal(ctx1.CacheKey, ctx2.CacheKey); + } + + [Fact] + public void Ctor_ThrowsOnNullScopes() + { + Assert.Throws(() => new TokenRequestContext(null!)); + } +} + +public class TokenCredentialTests +{ + private sealed class ConstantTokenCredential : TokenCredential + { + private readonly AccessToken _token; + + public ConstantTokenCredential(AccessToken token) => _token = token; + + public override ValueTask GetTokenAsync(TokenRequestContext context, CancellationToken ct = default) + => ValueTask.FromResult(_token); + } + + [Fact] + public async Task GetTokenAsync_ReturnsExpectedToken() + { + var expected = new AccessToken("async_tok", DateTimeOffset.UtcNow.AddHours(1)); + var cred = new ConstantTokenCredential(expected); + var ctx = new TokenRequestContext(["scope"]); + + var actual = await cred.GetTokenAsync(ctx); + + Assert.Equal(expected.Token, actual.Token); + } + + [Fact] + public void GetToken_SyncBridge_ReturnsSameAsAsync() + { + var expected = new AccessToken("sync_tok", DateTimeOffset.UtcNow.AddHours(1)); + var cred = new ConstantTokenCredential(expected); + var ctx = new TokenRequestContext(["scope"]); + + var actual = cred.GetToken(ctx); + + Assert.Equal(expected.Token, actual.Token); + } +} + +public class ApiKeyCredentialTests +{ + [Fact] + public void Ctor_DefaultsToAuthorizationHeader_AndNoScheme() + { + var cred = new ApiKeyCredential("my-key"); + + Assert.Equal("my-key", cred.Key); + Assert.Equal(HttpHeaderName.WellKnown.Authorization, cred.HeaderName); + Assert.Null(cred.Scheme); + } + + [Fact] + public void Ctor_CustomHeader_AndScheme() + { + var header = HttpHeaderName.Of("X-Api-Key"); + var cred = new ApiKeyCredential("k", header, "Bearer"); + + Assert.Equal("k", cred.Key); + Assert.Equal(header, cred.HeaderName); + Assert.Equal("Bearer", cred.Scheme); + } + + [Fact] + public void Ctor_ThrowsOnNullKey() + { + Assert.Throws(() => new ApiKeyCredential(null!)); + } + + [Fact] + public void Ctor_ThrowsOnEmptyKey() + { + Assert.Throws(() => new ApiKeyCredential(string.Empty)); + } +} + +public class BasicCredentialTests +{ + [Fact] + public void Ctor_SetsUsernameAndPassword() + { + var cred = new BasicCredential("user", "pass"); + + Assert.Equal("user", cred.Username); + Assert.Equal("pass", cred.Password); + } + + [Fact] + public void Ctor_ThrowsOnNullUsername() + { + Assert.Throws(() => new BasicCredential(null!, "p")); + } + + [Fact] + public void Ctor_ThrowsOnNullPassword() + { + Assert.Throws(() => new BasicCredential("u", null!)); + } + + [Fact] + public void ToBase64_ProducesCorrectEncoding() + { + var cred = new BasicCredential("user", "pass"); + var expected = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("user:pass")); + + Assert.Equal(expected, cred.ToBase64()); + } + + [Fact] + public void ToBase64_HandlesSpecialChars() + { + var cred = new BasicCredential("user@example.com", "p@ss:word"); + var expected = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("user@example.com:p@ss:word")); + + Assert.Equal(expected, cred.ToBase64()); + } +} diff --git a/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/ApiKeyAuthPolicyTests.cs b/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/ApiKeyAuthPolicyTests.cs new file mode 100644 index 0000000..4e05823 --- /dev/null +++ b/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/ApiKeyAuthPolicyTests.cs @@ -0,0 +1,309 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Auth; +using Dexpace.Sdk.Core.Client; +using Dexpace.Sdk.Core.Configuration; +using Dexpace.Sdk.Core.Http.Common; +using Dexpace.Sdk.Core.Http.Request; +using Dexpace.Sdk.Core.Http.Response; +using Dexpace.Sdk.Core.Pipeline; +using Dexpace.Sdk.Core.Pipeline.Policies; +using Xunit; + +namespace Dexpace.Sdk.Core.Tests.Pipeline.Policies; + +public sealed class ApiKeyAuthPolicyTests +{ + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private static Request MakeRequest(string url = "https://api.example.com/v1/items") + => Request.Get(url); + + private static DexpaceClientOptions MakeOptions() => new(); + + /// + /// Captures the last request received and returns a canned 200 OK. + /// + private sealed class CapturingTransport : IAsyncHttpClient + { + public Request? LastRequest { get; private set; } + + public Task ExecuteAsync(Request request, CancellationToken cancellationToken = default) + { + LastRequest = request; + return Task.FromResult(new Response(Status.Ok)); + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } + + // ------------------------------------------------------------------------- + // Stage + // ------------------------------------------------------------------------- + + [Fact] + public void Stage_IsAuth() + { + var policy = new ApiKeyAuthPolicy(new ApiKeyCredential("key123")); + Assert.Equal(PipelineStage.Auth, policy.Stage); + } + + // ------------------------------------------------------------------------- + // Header stamping — no scheme + // ------------------------------------------------------------------------- + + [Fact] + public async Task ProcessAsync_NoScheme_StampsKeyAsEntireHeaderValue() + { + var credential = new ApiKeyCredential("sk-test-abc"); + var transport = new CapturingTransport(); + var pipeline = new PipelineBuilder() + .Add(new ApiKeyAuthPolicy(credential)) + .Build(transport); + + await pipeline.SendAsync(MakeRequest(), MakeOptions()); + + var value = transport.LastRequest!.Headers.Get("Authorization"); + Assert.Equal("sk-test-abc", value); + } + + [Fact] + public async Task ProcessAsync_WithScheme_PrefixesSchemeBeforeKey() + { + var credential = new ApiKeyCredential("sk-test-abc", scheme: "Bearer"); + var transport = new CapturingTransport(); + var pipeline = new PipelineBuilder() + .Add(new ApiKeyAuthPolicy(credential)) + .Build(transport); + + await pipeline.SendAsync(MakeRequest(), MakeOptions()); + + var value = transport.LastRequest!.Headers.Get("Authorization"); + Assert.Equal("Bearer sk-test-abc", value); + } + + // ------------------------------------------------------------------------- + // Custom header name + // ------------------------------------------------------------------------- + + [Fact] + public async Task ProcessAsync_CustomHeader_StampsConfiguredHeader() + { + var xApiKey = HttpHeaderName.Of("X-Api-Key"); + var credential = new ApiKeyCredential("my-key", header: xApiKey); + var transport = new CapturingTransport(); + var pipeline = new PipelineBuilder() + .Add(new ApiKeyAuthPolicy(credential)) + .Build(transport); + + await pipeline.SendAsync(MakeRequest(), MakeOptions()); + + var value = transport.LastRequest!.Headers.Get("X-Api-Key"); + Assert.Equal("my-key", value); + // Authorization header must not be set + Assert.Null(transport.LastRequest.Headers.Get("Authorization")); + } + + [Fact] + public async Task ProcessAsync_CustomHeaderWithScheme_StampsCorrectValue() + { + var xApiKey = HttpHeaderName.Of("X-Api-Key"); + var credential = new ApiKeyCredential("my-key", header: xApiKey, scheme: "Token"); + var transport = new CapturingTransport(); + var pipeline = new PipelineBuilder() + .Add(new ApiKeyAuthPolicy(credential)) + .Build(transport); + + await pipeline.SendAsync(MakeRequest(), MakeOptions()); + + var value = transport.LastRequest!.Headers.Get("X-Api-Key"); + Assert.Equal("Token my-key", value); + } + + // ------------------------------------------------------------------------- + // Replaces pre-existing header value + // ------------------------------------------------------------------------- + + [Fact] + public async Task ProcessAsync_ReplacesExistingAuthorizationHeader() + { + var credential = new ApiKeyCredential("new-key"); + var request = MakeRequest() with + { + Headers = Headers.Empty.Set("Authorization", "old-value") + }; + + var transport = new CapturingTransport(); + var pipeline = new PipelineBuilder() + .Add(new ApiKeyAuthPolicy(credential)) + .Build(transport); + + await pipeline.SendAsync(request, MakeOptions()); + + var values = transport.LastRequest!.Headers.GetAll("Authorization"); + Assert.Single(values); + Assert.Equal("new-key", values[0]); + } + + // ------------------------------------------------------------------------- + // Cross-origin withholding + // ------------------------------------------------------------------------- + + [Fact] + public async Task ProcessAsync_SameOriginAfterRedirect_StampsCredential() + { + // Simulate the pipeline being called twice on contexts with same origin. + var credential = new ApiKeyCredential("sk-secret", scheme: "Bearer"); + var transport = new CapturingTransport(); + var pipeline = new PipelineBuilder() + .Add(new ApiKeyAuthPolicy(credential)) + .Build(transport); + + // Two independent calls — each gets a fresh PipelineContext, same origin. + await pipeline.SendAsync(MakeRequest("https://api.example.com/v1/a"), MakeOptions()); + var firstAuth = transport.LastRequest!.Headers.Get("Authorization"); + + await pipeline.SendAsync(MakeRequest("https://api.example.com/v1/b"), MakeOptions()); + var secondAuth = transport.LastRequest!.Headers.Get("Authorization"); + + Assert.Equal("Bearer sk-secret", firstAuth); + Assert.Equal("Bearer sk-secret", secondAuth); + } + + [Fact] + public async Task ProcessAsync_CrossOriginRequest_WithholdsCredential() + { + // Drive the policy directly against a context whose Request has been redirected + // to a different origin after the origin was first recorded. + var credential = new ApiKeyCredential("sk-secret", scheme: "Bearer"); + var options = MakeOptions(); + + // Context starts on the original origin. + var originalRequest = MakeRequest("https://api.example.com/v1/resource"); + var context = new PipelineContext(originalRequest, options); + + // Record the original origin by running the policy once against a no-op continuation. + var recordingTransport = new CapturingTransport(); + var recordingRunner = new PipelineRunner([], 0, recordingTransport); + var policy = new ApiKeyAuthPolicy(credential); + + // First run: records origin + stamps header, then calls continuation (which hits transport). + await policy.ProcessAsync(context, recordingRunner); + Assert.Equal("Bearer sk-secret", context.Request.Headers.Get("Authorization")); + + // Now mutate the context to simulate a cross-origin redirect. + context.Request = MakeRequest("https://other-service.example.org/callback"); + + // Reset Authorization so we can observe whether it gets stamped. + context.Request = context.Request with + { + Headers = Headers.Empty + }; + + var foreignTransport = new CapturingTransport(); + var foreignRunner = new PipelineRunner([], 0, foreignTransport); + + // Second run on same context: origin differs → credential must be withheld. + await policy.ProcessAsync(context, foreignRunner); + + Assert.Null(context.Request.Headers.Get("Authorization")); + } + + [Fact] + public async Task ProcessAsync_SameOriginRerun_StampsCredentialAgain() + { + // Same-origin retry: the policy should stamp on each run. + var credential = new ApiKeyCredential("retry-key"); + var options = MakeOptions(); + var request = MakeRequest("https://api.example.com/v1/resource"); + var context = new PipelineContext(request, options); + var transport = new CapturingTransport(); + var runner = new PipelineRunner([], 0, transport); + var policy = new ApiKeyAuthPolicy(credential); + + // First invocation records origin and stamps. + await policy.ProcessAsync(context, runner); + Assert.Equal("retry-key", context.Request.Headers.Get("Authorization")); + + // Reset the header to confirm the second stamp. + context.Request = context.Request with { Headers = Headers.Empty }; + + // Second invocation on same origin: must stamp again. + await policy.ProcessAsync(context, runner); + Assert.Equal("retry-key", context.Request.Headers.Get("Authorization")); + } + + [Fact] + public async Task ProcessAsync_CrossOriginRequest_StripsStalCredentialHeader() + { + // The request carries a stale Authorization header from the original hop. + // After the policy runs on a cross-origin request, that header must be absent — + // the foreign origin must never see it. + var credential = new ApiKeyCredential("sk-secret", scheme: "Bearer"); + var options = MakeOptions(); + + // Context starts on the original origin. + var originalRequest = MakeRequest("https://api.example.com/v1/resource"); + var context = new PipelineContext(originalRequest, options); + + var recordingTransport = new CapturingTransport(); + var recordingRunner = new PipelineRunner([], 0, recordingTransport); + var policy = new ApiKeyAuthPolicy(credential); + + // First run: records origin and stamps header. + await policy.ProcessAsync(context, recordingRunner); + Assert.Equal("Bearer sk-secret", context.Request.Headers.Get("Authorization")); + + // Simulate a cross-origin redirect where the credential header is still present + // (i.e. RedirectPolicy was NOT in the pipeline). + context.Request = MakeRequest("https://other-service.example.org/callback") with + { + Headers = Headers.Empty.Set("Authorization", "Bearer sk-secret") + }; + + var foreignTransport = new CapturingTransport(); + var foreignRunner = new PipelineRunner([], 0, foreignTransport); + + // Second run: different origin → stale header must be stripped. + await policy.ProcessAsync(context, foreignRunner); + + Assert.Null(context.Request.Headers.Get("Authorization")); + } + + [Fact] + public async Task ProcessAsync_CrossOriginRequest_StripsStaleCustomHeader() + { + // Same defense-in-depth check for a custom header (X-Api-Key) on cross-origin. + var xApiKey = HttpHeaderName.Of("X-Api-Key"); + var credential = new ApiKeyCredential("my-key", header: xApiKey); + var options = MakeOptions(); + + var originalRequest = MakeRequest("https://api.example.com/v1/resource"); + var context = new PipelineContext(originalRequest, options); + + var recordingTransport = new CapturingTransport(); + var recordingRunner = new PipelineRunner([], 0, recordingTransport); + var policy = new ApiKeyAuthPolicy(credential); + + // First run: records origin and stamps X-Api-Key. + await policy.ProcessAsync(context, recordingRunner); + Assert.Equal("my-key", context.Request.Headers.Get("X-Api-Key")); + + // Simulate cross-origin redirect with stale custom header still present. + context.Request = MakeRequest("https://other-service.example.org/callback") with + { + Headers = Headers.Empty.Set("X-Api-Key", "my-key") + }; + + var foreignTransport = new CapturingTransport(); + var foreignRunner = new PipelineRunner([], 0, foreignTransport); + + // Second run: different origin → stale X-Api-Key header must be stripped. + await policy.ProcessAsync(context, foreignRunner); + + Assert.Null(context.Request.Headers.Get("X-Api-Key")); + } +} diff --git a/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/BasicAuthPolicyTests.cs b/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/BasicAuthPolicyTests.cs new file mode 100644 index 0000000..3526443 --- /dev/null +++ b/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/BasicAuthPolicyTests.cs @@ -0,0 +1,205 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Text; +using Dexpace.Sdk.Core.Auth; +using Dexpace.Sdk.Core.Client; +using Dexpace.Sdk.Core.Configuration; +using Dexpace.Sdk.Core.Http.Common; +using Dexpace.Sdk.Core.Http.Request; +using Dexpace.Sdk.Core.Http.Response; +using Dexpace.Sdk.Core.Pipeline; +using Dexpace.Sdk.Core.Pipeline.Policies; +using Xunit; + +namespace Dexpace.Sdk.Core.Tests.Pipeline.Policies; + +public sealed class BasicAuthPolicyTests +{ + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private static Request MakeRequest(string url = "https://api.example.com/v1/items") + => Request.Get(url); + + private static DexpaceClientOptions MakeOptions() => new(); + + private static string Base64(string user, string pass) + => Convert.ToBase64String(Encoding.UTF8.GetBytes($"{user}:{pass}")); + + /// + /// Captures the last request received and returns a canned 200 OK. + /// + private sealed class CapturingTransport : IAsyncHttpClient + { + public Request? LastRequest { get; private set; } + + public Task ExecuteAsync(Request request, CancellationToken cancellationToken = default) + { + LastRequest = request; + return Task.FromResult(new Response(Status.Ok)); + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } + + // ------------------------------------------------------------------------- + // Stage + // ------------------------------------------------------------------------- + + [Fact] + public void Stage_IsAuth() + { + var policy = new BasicAuthPolicy(new BasicCredential("user", "pass")); + Assert.Equal(PipelineStage.Auth, policy.Stage); + } + + // ------------------------------------------------------------------------- + // Header stamping + // ------------------------------------------------------------------------- + + [Fact] + public async Task ProcessAsync_StampsBasicAuthorizationHeader() + { + var credential = new BasicCredential("alice", "s3cr3t"); + var transport = new CapturingTransport(); + var pipeline = new PipelineBuilder() + .Add(new BasicAuthPolicy(credential)) + .Build(transport); + + await pipeline.SendAsync(MakeRequest(), MakeOptions()); + + var value = transport.LastRequest!.Headers.Get("Authorization"); + Assert.Equal($"Basic {Base64("alice", "s3cr3t")}", value); + } + + [Fact] + public async Task ProcessAsync_EmptyPassword_StampsCorrectly() + { + var credential = new BasicCredential("user", string.Empty); + var transport = new CapturingTransport(); + var pipeline = new PipelineBuilder() + .Add(new BasicAuthPolicy(credential)) + .Build(transport); + + await pipeline.SendAsync(MakeRequest(), MakeOptions()); + + var value = transport.LastRequest!.Headers.Get("Authorization"); + Assert.Equal($"Basic {Base64("user", "")}", value); + } + + [Fact] + public async Task ProcessAsync_ReplacesExistingAuthorizationHeader() + { + var credential = new BasicCredential("bob", "hunter2"); + var request = MakeRequest() with + { + Headers = Headers.Empty.Set("Authorization", "Bearer old-token") + }; + + var transport = new CapturingTransport(); + var pipeline = new PipelineBuilder() + .Add(new BasicAuthPolicy(credential)) + .Build(transport); + + await pipeline.SendAsync(request, MakeOptions()); + + var values = transport.LastRequest!.Headers.GetAll("Authorization"); + Assert.Single(values); + Assert.StartsWith("Basic ", values[0], StringComparison.Ordinal); + } + + // ------------------------------------------------------------------------- + // Cross-origin withholding + // ------------------------------------------------------------------------- + + [Fact] + public async Task ProcessAsync_CrossOriginRequest_WithholdsCredential() + { + var credential = new BasicCredential("user", "pass"); + var options = MakeOptions(); + + var originalRequest = MakeRequest("https://api.example.com/v1/resource"); + var context = new PipelineContext(originalRequest, options); + + var recordingTransport = new CapturingTransport(); + var recordingRunner = new PipelineRunner([], 0, recordingTransport); + var policy = new BasicAuthPolicy(credential); + + // First run: records origin and stamps header. + await policy.ProcessAsync(context, recordingRunner); + Assert.NotNull(context.Request.Headers.Get("Authorization")); + + // Simulate cross-origin redirect. + context.Request = MakeRequest("https://other-service.example.org/callback") with + { + Headers = Headers.Empty + }; + + var foreignTransport = new CapturingTransport(); + var foreignRunner = new PipelineRunner([], 0, foreignTransport); + + // Second run: different origin → credential must be withheld. + await policy.ProcessAsync(context, foreignRunner); + Assert.Null(context.Request.Headers.Get("Authorization")); + } + + [Fact] + public async Task ProcessAsync_SameOriginRerun_StampsCredentialAgain() + { + var credential = new BasicCredential("user", "pass"); + var options = MakeOptions(); + var request = MakeRequest("https://api.example.com/v1/resource"); + var context = new PipelineContext(request, options); + var transport = new CapturingTransport(); + var runner = new PipelineRunner([], 0, transport); + var policy = new BasicAuthPolicy(credential); + + await policy.ProcessAsync(context, runner); + Assert.NotNull(context.Request.Headers.Get("Authorization")); + + // Reset the header. + context.Request = context.Request with { Headers = Headers.Empty }; + + // Same origin: must stamp again. + await policy.ProcessAsync(context, runner); + Assert.NotNull(context.Request.Headers.Get("Authorization")); + Assert.StartsWith("Basic ", context.Request.Headers.Get("Authorization"), StringComparison.Ordinal); + } + + [Fact] + public async Task ProcessAsync_CrossOriginRequest_StripsStaleAuthorizationHeader() + { + // The request carries a stale Authorization header from the original hop. + // After the policy runs on a cross-origin request, that header must be absent — + // the foreign origin must never see it, even without RedirectPolicy in the pipeline. + var credential = new BasicCredential("user", "pass"); + var options = MakeOptions(); + + var originalRequest = MakeRequest("https://api.example.com/v1/resource"); + var context = new PipelineContext(originalRequest, options); + + var recordingTransport = new CapturingTransport(); + var recordingRunner = new PipelineRunner([], 0, recordingTransport); + var policy = new BasicAuthPolicy(credential); + + // First run: records origin and stamps header. + await policy.ProcessAsync(context, recordingRunner); + Assert.NotNull(context.Request.Headers.Get("Authorization")); + + // Simulate a cross-origin redirect with the stale Authorization header still in place. + context.Request = MakeRequest("https://other-service.example.org/callback") with + { + Headers = Headers.Empty.Set("Authorization", $"Basic {Base64("user", "pass")}") + }; + + var foreignTransport = new CapturingTransport(); + var foreignRunner = new PipelineRunner([], 0, foreignTransport); + + // Second run: different origin → stale Authorization header must be stripped. + await policy.ProcessAsync(context, foreignRunner); + + Assert.Null(context.Request.Headers.Get("Authorization")); + } +} diff --git a/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/BearerTokenAuthPolicyTests.cs b/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/BearerTokenAuthPolicyTests.cs new file mode 100644 index 0000000..01bacd8 --- /dev/null +++ b/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/BearerTokenAuthPolicyTests.cs @@ -0,0 +1,281 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Auth; +using Dexpace.Sdk.Core.Client; +using Dexpace.Sdk.Core.Configuration; +using Dexpace.Sdk.Core.Http.Common; +using Dexpace.Sdk.Core.Http.Request; +using Dexpace.Sdk.Core.Http.Response; +using Dexpace.Sdk.Core.Pipeline; +using Dexpace.Sdk.Core.Pipeline.Policies; +using Xunit; + +namespace Dexpace.Sdk.Core.Tests.Pipeline.Policies; + +public sealed class BearerTokenAuthPolicyTests +{ + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private static readonly DateTimeOffset FarFuture = + new DateTimeOffset(2099, 1, 1, 0, 0, 0, TimeSpan.Zero); + + private static Request MakeRequest(string url = "https://api.example.com/v1/items") + => Request.Get(url); + + private static DexpaceClientOptions MakeOptions() => new(); + + /// + /// Captures the last request received and returns a canned 200 OK. + /// + private sealed class CapturingTransport : IAsyncHttpClient + { + public Request? LastRequest { get; private set; } + + public Task ExecuteAsync(Request request, CancellationToken cancellationToken = default) + { + LastRequest = request; + return Task.FromResult(new Response(Status.Ok)); + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } + + /// + /// A that returns a canned token and tracks how many times + /// was called. + /// + private sealed class FakeTokenCredential(string token) : TokenCredential + { + private int _callCount; + + /// Number of times was called. + public int CallCount => _callCount; + + public override ValueTask GetTokenAsync( + TokenRequestContext context, + CancellationToken ct = default) + { + Interlocked.Increment(ref _callCount); + return new ValueTask(new AccessToken(token, FarFuture)); + } + } + + // ------------------------------------------------------------------------- + // Stage + // ------------------------------------------------------------------------- + + [Fact] + public void Stage_IsAuth() + { + var policy = new BearerTokenAuthPolicy( + new FakeTokenCredential("tok"), + "https://api.example.com/.default"); + + Assert.Equal(PipelineStage.Auth, policy.Stage); + } + + // ------------------------------------------------------------------------- + // Bearer token stamping + // ------------------------------------------------------------------------- + + [Fact] + public async Task ProcessAsync_StampsBearerTokenInAuthorizationHeader() + { + var credential = new FakeTokenCredential("abc123"); + var transport = new CapturingTransport(); + var pipeline = new PipelineBuilder() + .Add(new BearerTokenAuthPolicy(credential, "scope1", "scope2")) + .Build(transport); + + await pipeline.SendAsync(MakeRequest(), MakeOptions()); + + var value = transport.LastRequest!.Headers.Get("Authorization"); + Assert.Equal("Bearer abc123", value); + } + + [Fact] + public async Task ProcessAsync_ReplacesExistingAuthorizationHeader() + { + var credential = new FakeTokenCredential("fresh-token"); + var request = MakeRequest() with + { + Headers = Headers.Empty.Set("Authorization", "Bearer old-token") + }; + + var transport = new CapturingTransport(); + var pipeline = new PipelineBuilder() + .Add(new BearerTokenAuthPolicy(credential)) + .Build(transport); + + await pipeline.SendAsync(request, MakeOptions()); + + var values = transport.LastRequest!.Headers.GetAll("Authorization"); + Assert.Single(values); + Assert.Equal("Bearer fresh-token", values[0]); + } + + // ------------------------------------------------------------------------- + // Cache reuse — credential called only once across two requests + // ------------------------------------------------------------------------- + + [Fact] + public async Task ProcessAsync_CacheReused_CredentialCalledOnceAcrossTwoRequests() + { + // Two pipeline sends share the same BearerTokenAuthPolicy instance, so they share + // the same AccessTokenCache. The token expires far in the future, so the second + // send must reuse the cached token without calling the credential again. + var credential = new FakeTokenCredential("shared-token"); + var transport = new CapturingTransport(); + var policy = new BearerTokenAuthPolicy(credential, "read", "write"); + var pipeline = new PipelineBuilder() + .Add(policy) + .Build(transport); + + await pipeline.SendAsync(MakeRequest(), MakeOptions()); + var firstValue = transport.LastRequest!.Headers.Get("Authorization"); + + await pipeline.SendAsync(MakeRequest(), MakeOptions()); + var secondValue = transport.LastRequest!.Headers.Get("Authorization"); + + Assert.Equal("Bearer shared-token", firstValue); + Assert.Equal("Bearer shared-token", secondValue); + Assert.Equal(1, credential.CallCount); + } + + // ------------------------------------------------------------------------- + // Scopes are forwarded to the credential + // ------------------------------------------------------------------------- + + [Fact] + public async Task ProcessAsync_ForwardsConfiguredScopesToCredential() + { + string[]? capturedScopes = null; + var credential = new CapturingScopesCredential( + "scope-token", + scopes => capturedScopes = scopes); + + var transport = new CapturingTransport(); + var pipeline = new PipelineBuilder() + .Add(new BearerTokenAuthPolicy(credential, "openid", "profile")) + .Build(transport); + + await pipeline.SendAsync(MakeRequest(), MakeOptions()); + + Assert.NotNull(capturedScopes); + Assert.Equal(["openid", "profile"], capturedScopes!); + } + + // ------------------------------------------------------------------------- + // Cross-origin withholding + // ------------------------------------------------------------------------- + + [Fact] + public async Task ProcessAsync_CrossOriginRequest_WithholdsCredential() + { + var credential = new FakeTokenCredential("secret-bearer"); + var options = MakeOptions(); + + var originalRequest = MakeRequest("https://api.example.com/v1/resource"); + var context = new PipelineContext(originalRequest, options); + + var recordingTransport = new CapturingTransport(); + var recordingRunner = new PipelineRunner([], 0, recordingTransport); + var policy = new BearerTokenAuthPolicy(credential, "scope"); + + // First run: record origin and stamp. + await policy.ProcessAsync(context, recordingRunner); + Assert.Equal("Bearer secret-bearer", context.Request.Headers.Get("Authorization")); + + // Simulate cross-origin redirect. + context.Request = MakeRequest("https://other-service.example.org/callback") with + { + Headers = Headers.Empty + }; + + var foreignTransport = new CapturingTransport(); + var foreignRunner = new PipelineRunner([], 0, foreignTransport); + + // Second run on same context: different origin → credential must be withheld. + await policy.ProcessAsync(context, foreignRunner); + Assert.Null(context.Request.Headers.Get("Authorization")); + } + + [Fact] + public async Task ProcessAsync_SameOriginRerun_StampsBearerAgain() + { + var credential = new FakeTokenCredential("retry-bearer"); + var options = MakeOptions(); + var request = MakeRequest("https://api.example.com/v1/resource"); + var context = new PipelineContext(request, options); + var transport = new CapturingTransport(); + var runner = new PipelineRunner([], 0, transport); + var policy = new BearerTokenAuthPolicy(credential, "scope"); + + await policy.ProcessAsync(context, runner); + Assert.Equal("Bearer retry-bearer", context.Request.Headers.Get("Authorization")); + + context.Request = context.Request with { Headers = Headers.Empty }; + + // Same origin retry: must stamp again. + await policy.ProcessAsync(context, runner); + Assert.Equal("Bearer retry-bearer", context.Request.Headers.Get("Authorization")); + } + + [Fact] + public async Task ProcessAsync_CrossOriginRequest_StripsStaleAuthorizationHeader() + { + // The request carries a stale Authorization header from the original hop. + // After the policy runs on a cross-origin request, that header must be absent — + // the foreign origin must never see it, even without RedirectPolicy in the pipeline. + var credential = new FakeTokenCredential("secret-bearer"); + var options = MakeOptions(); + + var originalRequest = MakeRequest("https://api.example.com/v1/resource"); + var context = new PipelineContext(originalRequest, options); + + var recordingTransport = new CapturingTransport(); + var recordingRunner = new PipelineRunner([], 0, recordingTransport); + var policy = new BearerTokenAuthPolicy(credential, "scope"); + + // First run: records origin, stamps header (calls credential once). + await policy.ProcessAsync(context, recordingRunner); + Assert.Equal("Bearer secret-bearer", context.Request.Headers.Get("Authorization")); + var callCountAfterFirstRun = credential.CallCount; + + // Simulate a cross-origin redirect with the stale Authorization header still in place. + context.Request = MakeRequest("https://other-service.example.org/callback") with + { + Headers = Headers.Empty.Set("Authorization", "Bearer secret-bearer") + }; + + var foreignTransport = new CapturingTransport(); + var foreignRunner = new PipelineRunner([], 0, foreignTransport); + + // Second run: different origin → stale Authorization header must be stripped. + // Crucially, the token cache / credential must NOT be called again. + await policy.ProcessAsync(context, foreignRunner); + + Assert.Null(context.Request.Headers.Get("Authorization")); + Assert.Equal(callCountAfterFirstRun, credential.CallCount); + } + + // ------------------------------------------------------------------------- + // Helper: captures scopes passed to GetTokenAsync + // ------------------------------------------------------------------------- + + private sealed class CapturingScopesCredential( + string token, + Action onGetToken) : TokenCredential + { + public override ValueTask GetTokenAsync( + TokenRequestContext context, + CancellationToken ct = default) + { + onGetToken([.. context.Scopes]); + return new ValueTask(new AccessToken(token, FarFuture)); + } + } +}