From d588ce7c5f9cbce373dee57f2870da942823541f Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Tue, 16 Jun 2026 17:35:07 +0300 Subject: [PATCH 1/6] feat: add auth credential types Add AccessToken (readonly struct with Token, ExpiresOn, RefreshOn), TokenRequestContext (scopes + claims + stable CacheKey), TokenCredential (abstract base with blocking sync bridge), ApiKeyCredential (header/scheme stamping), and BasicCredential (RFC 7617 ToBase64). 21 new tests covering construction, property round-trips, cache-key stability, and sync bridge. Co-Authored-By: Claude Opus 4.8 --- src/Dexpace.Sdk.Core/Auth/AccessToken.cs | 53 +++++ src/Dexpace.Sdk.Core/Auth/ApiKeyCredential.cs | 56 +++++ src/Dexpace.Sdk.Core/Auth/BasicCredential.cs | 50 ++++ src/Dexpace.Sdk.Core/Auth/TokenCredential.cs | 48 ++++ .../Auth/TokenRequestContext.cs | 77 ++++++ .../Auth/CredentialTypesTests.cs | 222 ++++++++++++++++++ 6 files changed, 506 insertions(+) create mode 100644 src/Dexpace.Sdk.Core/Auth/AccessToken.cs create mode 100644 src/Dexpace.Sdk.Core/Auth/ApiKeyCredential.cs create mode 100644 src/Dexpace.Sdk.Core/Auth/BasicCredential.cs create mode 100644 src/Dexpace.Sdk.Core/Auth/TokenCredential.cs create mode 100644 src/Dexpace.Sdk.Core/Auth/TokenRequestContext.cs create mode 100644 tests/Dexpace.Sdk.Core.Tests/Auth/CredentialTypesTests.cs 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/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/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()); + } +} From 2a9003fba5733f3cc9545082813007e6ba961b7f Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Tue, 16 Jun 2026 17:37:15 +0300 Subject: [PATCH 2/6] feat: add AccessTokenCache with proactive refresh and single-flight Add AccessTokenCache wrapping a TokenCredential. Tokens are served from cache while now < ExpiresOn and (RefreshOn is null or now < RefreshOn). Once RefreshOn is reached, a SemaphoreSlim (one per cache key) serializes refreshes so only one credential call fires under concurrent load. Refresh failures while a valid token exists are swallowed; failures with no valid token propagate. TimeProvider is injected for deterministic testing. 7 new tests covering: within-validity caching, proactive RefreshOn trigger, ExpiresOn expiry, 50-concurrent-caller single-flight, swallowed failure while valid, propagated failure with no valid token, and independent per-key caching. Co-Authored-By: Claude Opus 4.8 --- src/Dexpace.Sdk.Core/Auth/AccessTokenCache.cs | 133 ++++++++ .../Auth/AccessTokenCacheTests.cs | 314 ++++++++++++++++++ 2 files changed, 447 insertions(+) create mode 100644 src/Dexpace.Sdk.Core/Auth/AccessTokenCache.cs create mode 100644 tests/Dexpace.Sdk.Core.Tests/Auth/AccessTokenCacheTests.cs diff --git a/src/Dexpace.Sdk.Core/Auth/AccessTokenCache.cs b/src/Dexpace.Sdk.Core/Auth/AccessTokenCache.cs new file mode 100644 index 0000000..56efbb6 --- /dev/null +++ b/src/Dexpace.Sdk.Core/Auth/AccessTokenCache.cs @@ -0,0 +1,133 @@ +// 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 goroutine 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: cached and no refresh needed — return without acquiring the semaphore. + if (entry.Token is { } cached && IsValid(cached, now) && !NeedsRefresh(cached, now)) + { + return cached; + } + + // Slow path: acquire the semaphore for this key. + await entry.Semaphore.WaitAsync(ct).ConfigureAwait(false); + try + { + // Double-check after acquiring. + now = _time.GetUtcNow(); + if (entry.Token is { } rechecked && IsValid(rechecked, now) && !NeedsRefresh(rechecked, now)) + { + return rechecked; + } + + // Try to refresh from the credential. + try + { + var fresh = await _credential.GetTokenAsync(context, ct).ConfigureAwait(false); + entry.Token = fresh; + return fresh; + } + catch + { + // If we still have a valid (not-yet-expired) token, swallow the failure. + if (entry.Token is { } fallback && IsValid(fallback, now)) + { + return fallback; + } + + 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; + + private sealed class CacheEntry + { + /// The most recently fetched token, or if none. + public AccessToken? Token { get; set; } + + /// + /// A semaphore (initial count = 1) that serializes refresh calls for this key. + /// + public SemaphoreSlim Semaphore { get; } = new SemaphoreSlim(1, 1); + } +} 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); + } +} From 88ad3295788137ad0b01ce3b3afe547b89525a9b Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Tue, 16 Jun 2026 17:43:44 +0300 Subject: [PATCH 3/6] fix: make AccessTokenCache token publication reference-atomic AccessToken is a multi-field struct (string ref + two DateTimeOffset + nullable flag). Writing it directly to a bare field and reading it on the lock-free fast path races: the .NET memory model only guarantees atomicity for pointer/word-size writes, so a concurrent fast-path reader could observe a torn or partially-written value. Fix: introduce a private sealed TokenHolder that boxes the struct behind a single reference. CacheEntry replaces its AccessToken? field with a volatile TokenHolder? _holder. The volatile modifier gives acquire semantics on every fast-path read and release semantics on every slow-path write, and reference reads/writes are unconditionally atomic, so no lock is needed on the fast path. Also fix the failure-while-valid catch block: the now variable was captured before the possibly-slow GetTokenAsync call. The catch now re-samples the clock (nowAfterFailure) so a token that expired during a sluggish failing refresh is not incorrectly returned as valid. Minor: replace "goroutine" (Go terminology) with "caller" in the XML doc. Co-Authored-By: Claude Opus 4.8 --- src/Dexpace.Sdk.Core/Auth/AccessTokenCache.cs | 63 +++++++++++++++---- 1 file changed, 51 insertions(+), 12 deletions(-) diff --git a/src/Dexpace.Sdk.Core/Auth/AccessTokenCache.cs b/src/Dexpace.Sdk.Core/Auth/AccessTokenCache.cs index 56efbb6..b2a73af 100644 --- a/src/Dexpace.Sdk.Core/Auth/AccessTokenCache.cs +++ b/src/Dexpace.Sdk.Core/Auth/AccessTokenCache.cs @@ -18,7 +18,7 @@ namespace Dexpace.Sdk.Core.Auth; /// /// /// -/// Once either condition fails, a single goroutine acquires the per-key semaphore and calls +/// 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. /// @@ -71,10 +71,13 @@ public async ValueTask GetAsync(TokenRequestContext context, Cancel var entry = _entries.GetOrAdd(context.CacheKey, static _ => new CacheEntry()); var now = _time.GetUtcNow(); - // Fast path: cached and no refresh needed — return without acquiring the semaphore. - if (entry.Token is { } cached && IsValid(cached, now) && !NeedsRefresh(cached, now)) + // 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 cached; + return fastHolder.Token; } // Slow path: acquire the semaphore for this key. @@ -83,24 +86,30 @@ public async ValueTask GetAsync(TokenRequestContext context, Cancel { // Double-check after acquiring. now = _time.GetUtcNow(); - if (entry.Token is { } rechecked && IsValid(rechecked, now) && !NeedsRefresh(rechecked, now)) + var recheckedHolder = entry.Holder; + if (recheckedHolder is not null && IsValid(recheckedHolder.Token, now) && !NeedsRefresh(recheckedHolder.Token, now)) { - return rechecked; + return recheckedHolder.Token; } // Try to refresh from the credential. try { var fresh = await _credential.GetTokenAsync(context, ct).ConfigureAwait(false); - entry.Token = fresh; + // Volatile write releases the fully-constructed holder to all threads. + entry.Holder = new TokenHolder(fresh); return fresh; } catch { - // If we still have a valid (not-yet-expired) token, swallow the failure. - if (entry.Token is { } fallback && IsValid(fallback, now)) + // 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 fallback; + return fallbackHolder.Token; } throw; @@ -120,10 +129,40 @@ private static bool IsValid(AccessToken token, DateTimeOffset now) => 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 { - /// The most recently fetched token, or if none. - public AccessToken? Token { get; set; } + // 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. From 68bac554715aa82941ff302086555e3431c9e6f0 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Tue, 16 Jun 2026 17:48:31 +0300 Subject: [PATCH 4/6] feat: add API-key and basic auth policies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces three new files under src/Dexpace.Sdk.Core/Pipeline/Policies/: - AuthorizationPolicy — abstract base class at PipelineStage.Auth that implements cross-origin withholding: on the first invocation the request origin (scheme + host + port) is recorded in the context property bag under "dexpace.auth.origin"; subsequent invocations on a context whose Request has moved to a different origin skip stamping and forward to the continuation unchanged. Seals Stage and ProcessAsync so subclasses only implement GetCredentialAsync. - ApiKeyAuthPolicy(ApiKeyCredential) — stamps credential.HeaderName with credential.Key when Scheme is null, or " " otherwise. - BasicAuthPolicy(BasicCredential) — stamps Authorization with "Basic ", pre-computing the value at construction time. Tests added (15 cases): stage assertion, header-value correctness with and without scheme, custom header name, replace-existing-header, same-origin re-stamp, and cross-origin withholding for both policies. Co-Authored-By: Claude Opus 4.8 --- .../Pipeline/Policies/ApiKeyAuthPolicy.cs | 58 +++++ .../Pipeline/Policies/AuthorizationPolicy.cs | 92 +++++++ .../Pipeline/Policies/BasicAuthPolicy.cs | 47 ++++ .../Policies/ApiKeyAuthPolicyTests.cs | 238 ++++++++++++++++++ .../Pipeline/Policies/BasicAuthPolicyTests.cs | 170 +++++++++++++ 5 files changed, 605 insertions(+) create mode 100644 src/Dexpace.Sdk.Core/Pipeline/Policies/ApiKeyAuthPolicy.cs create mode 100644 src/Dexpace.Sdk.Core/Pipeline/Policies/AuthorizationPolicy.cs create mode 100644 src/Dexpace.Sdk.Core/Pipeline/Policies/BasicAuthPolicy.cs create mode 100644 tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/ApiKeyAuthPolicyTests.cs create mode 100644 tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/BasicAuthPolicyTests.cs 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..812c7ff --- /dev/null +++ b/src/Dexpace.Sdk.Core/Pipeline/Policies/ApiKeyAuthPolicy.cs @@ -0,0 +1,58 @@ +// 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; + +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 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..1533bda --- /dev/null +++ b/src/Dexpace.Sdk.Core/Pipeline/Policies/AuthorizationPolicy.cs @@ -0,0 +1,92 @@ +// 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 is +/// withheld and the continuation is called unchanged — complementing +/// 's header-stripping behaviour. +/// +/// +/// 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. The base class performs the Headers.Set write and +/// continuation.RunAsync call. +/// +/// +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; + + /// + 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 — withhold credential. + 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..61a90c8 --- /dev/null +++ b/src/Dexpace.Sdk.Core/Pipeline/Policies/BasicAuthPolicy.cs @@ -0,0 +1,47 @@ +// 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 ValueTask<(string HeaderName, string HeaderValue)> GetCredentialAsync( + PipelineContext context) + { + return new ValueTask<(string, string)>( + (HttpHeaderName.WellKnown.Authorization.Original, _headerValue)); + } +} 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..fb89aa7 --- /dev/null +++ b/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/ApiKeyAuthPolicyTests.cs @@ -0,0 +1,238 @@ +// 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")); + } +} 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..da9d38e --- /dev/null +++ b/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/BasicAuthPolicyTests.cs @@ -0,0 +1,170 @@ +// 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); + } +} From b98d2b339902a54bc48a8730474098055d7d14ee Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Tue, 16 Jun 2026 17:49:50 +0300 Subject: [PATCH 5/6] feat: add bearer-token auth policy Introduces BearerTokenAuthPolicy in src/Dexpace.Sdk.Core/Pipeline/Policies/: - BearerTokenAuthPolicy(TokenCredential credential, params string[] scopes) creates exactly one AccessTokenCache at construction time, shared across all pipeline invocations. On each same-origin request it calls AccessTokenCache.GetAsync with the configured TokenRequestContext, then stamps Authorization: Bearer . Cross-origin withholding is inherited from AuthorizationPolicy. Tests added (7 cases): stage assertion, correct "Bearer " value, replace-existing-header, cache reused across two sends (credential called exactly once), scopes forwarded to the credential, cross-origin withholding, and same-origin re-stamp. Co-Authored-By: Claude Opus 4.8 --- .../Policies/BearerTokenAuthPolicy.cs | 76 ++++++ .../Policies/BearerTokenAuthPolicyTests.cs | 243 ++++++++++++++++++ 2 files changed, 319 insertions(+) create mode 100644 src/Dexpace.Sdk.Core/Pipeline/Policies/BearerTokenAuthPolicy.cs create mode 100644 tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/BearerTokenAuthPolicyTests.cs 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..831e18e --- /dev/null +++ b/src/Dexpace.Sdk.Core/Pipeline/Policies/BearerTokenAuthPolicy.cs @@ -0,0 +1,76 @@ +// 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 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/Pipeline/Policies/BearerTokenAuthPolicyTests.cs b/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/BearerTokenAuthPolicyTests.cs new file mode 100644 index 0000000..18aaac7 --- /dev/null +++ b/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/BearerTokenAuthPolicyTests.cs @@ -0,0 +1,243 @@ +// 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")); + } + + // ------------------------------------------------------------------------- + // 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)); + } + } +} From b658460d11ee225f47fae2941ce528935a1a8f22 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Tue, 16 Jun 2026 17:58:03 +0300 Subject: [PATCH 6/6] fix: strip credential header on cross-origin hop (defense-in-depth) AuthorizationPolicy previously only declined to re-stamp credentials on cross-origin redirects, relying on RedirectPolicy to remove the header. A consumer who composes auth without RedirectPolicy, or who disables sensitive-header stripping there, would forward a stale credential to the foreign origin unchanged. The policy now actively removes the credential header it owns before calling the continuation on a cross-origin hop. WithheldHeaderName is a new abstract property on AuthorizationPolicy; each subclass returns the header it stamps (credential.HeaderName for ApiKeyAuthPolicy; HttpHeaderName.WellKnown.Authorization for Basic and Bearer). The property is accessed only on the withhold branch, so it never triggers token-cache or credential resolution. New tests confirm the stale-header case for all three policies; the Bearer test also asserts that the credential call count is unchanged by a withheld cross-origin run. All 235 core tests pass. Co-Authored-By: Claude Opus 4.8 --- .../Pipeline/Policies/ApiKeyAuthPolicy.cs | 4 ++ .../Pipeline/Policies/AuthorizationPolicy.cs | 31 ++++++-- .../Pipeline/Policies/BasicAuthPolicy.cs | 3 + .../Policies/BearerTokenAuthPolicy.cs | 3 + .../Policies/ApiKeyAuthPolicyTests.cs | 71 +++++++++++++++++++ .../Pipeline/Policies/BasicAuthPolicyTests.cs | 35 +++++++++ .../Policies/BearerTokenAuthPolicyTests.cs | 38 ++++++++++ 7 files changed, 179 insertions(+), 6 deletions(-) diff --git a/src/Dexpace.Sdk.Core/Pipeline/Policies/ApiKeyAuthPolicy.cs b/src/Dexpace.Sdk.Core/Pipeline/Policies/ApiKeyAuthPolicy.cs index 812c7ff..30129cd 100644 --- a/src/Dexpace.Sdk.Core/Pipeline/Policies/ApiKeyAuthPolicy.cs +++ b/src/Dexpace.Sdk.Core/Pipeline/Policies/ApiKeyAuthPolicy.cs @@ -2,6 +2,7 @@ // 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; @@ -44,6 +45,9 @@ public ApiKeyAuthPolicy(ApiKeyCredential credential) _credential = credential; } + /// + protected override HttpHeaderName WithheldHeaderName => _credential.HeaderName; + /// protected override ValueTask<(string HeaderName, string HeaderValue)> GetCredentialAsync( PipelineContext context) diff --git a/src/Dexpace.Sdk.Core/Pipeline/Policies/AuthorizationPolicy.cs b/src/Dexpace.Sdk.Core/Pipeline/Policies/AuthorizationPolicy.cs index 1533bda..acde414 100644 --- a/src/Dexpace.Sdk.Core/Pipeline/Policies/AuthorizationPolicy.cs +++ b/src/Dexpace.Sdk.Core/Pipeline/Policies/AuthorizationPolicy.cs @@ -12,9 +12,11 @@ namespace Dexpace.Sdk.Core.Pipeline.Policies; /// /// 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 is -/// withheld and the continuation is called unchanged — complementing -/// 's header-stripping behaviour. +/// 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 @@ -25,8 +27,11 @@ namespace Dexpace.Sdk.Core.Pipeline.Policies; /// /// /// Derived classes must implement to supply the header name -/// and value to stamp. The base class performs the Headers.Set write and -/// continuation.RunAsync call. +/// 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 @@ -37,6 +42,13 @@ public abstract class AuthorizationPolicy : HttpPipelinePolicy /// 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) { @@ -52,7 +64,14 @@ public sealed override async ValueTask ProcessAsync(PipelineContext context, Pip } else if (!string.Equals(recordedOrigin, currentOrigin, StringComparison.OrdinalIgnoreCase)) { - // Request has been redirected to a different origin — withhold credential. + // 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; } diff --git a/src/Dexpace.Sdk.Core/Pipeline/Policies/BasicAuthPolicy.cs b/src/Dexpace.Sdk.Core/Pipeline/Policies/BasicAuthPolicy.cs index 61a90c8..05e74f0 100644 --- a/src/Dexpace.Sdk.Core/Pipeline/Policies/BasicAuthPolicy.cs +++ b/src/Dexpace.Sdk.Core/Pipeline/Policies/BasicAuthPolicy.cs @@ -37,6 +37,9 @@ public BasicAuthPolicy(BasicCredential credential) _headerValue = $"Basic {credential.ToBase64()}"; } + /// + protected override HttpHeaderName WithheldHeaderName => HttpHeaderName.WellKnown.Authorization; + /// protected override ValueTask<(string HeaderName, string HeaderValue)> GetCredentialAsync( PipelineContext context) diff --git a/src/Dexpace.Sdk.Core/Pipeline/Policies/BearerTokenAuthPolicy.cs b/src/Dexpace.Sdk.Core/Pipeline/Policies/BearerTokenAuthPolicy.cs index 831e18e..5a69674 100644 --- a/src/Dexpace.Sdk.Core/Pipeline/Policies/BearerTokenAuthPolicy.cs +++ b/src/Dexpace.Sdk.Core/Pipeline/Policies/BearerTokenAuthPolicy.cs @@ -63,6 +63,9 @@ public BearerTokenAuthPolicy(TokenCredential credential, params string[] scopes) _tokenRequestContext = new TokenRequestContext(scopes); } + /// + protected override HttpHeaderName WithheldHeaderName => HttpHeaderName.WellKnown.Authorization; + /// protected override async ValueTask<(string HeaderName, string HeaderValue)> GetCredentialAsync( PipelineContext context) diff --git a/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/ApiKeyAuthPolicyTests.cs b/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/ApiKeyAuthPolicyTests.cs index fb89aa7..4e05823 100644 --- a/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/ApiKeyAuthPolicyTests.cs +++ b/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/ApiKeyAuthPolicyTests.cs @@ -235,4 +235,75 @@ public async Task ProcessAsync_SameOriginRerun_StampsCredentialAgain() 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 index da9d38e..3526443 100644 --- a/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/BasicAuthPolicyTests.cs +++ b/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/BasicAuthPolicyTests.cs @@ -167,4 +167,39 @@ public async Task ProcessAsync_SameOriginRerun_StampsCredentialAgain() 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 index 18aaac7..01bacd8 100644 --- a/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/BearerTokenAuthPolicyTests.cs +++ b/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/BearerTokenAuthPolicyTests.cs @@ -224,6 +224,44 @@ public async Task ProcessAsync_SameOriginRerun_StampsBearerAgain() 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 // -------------------------------------------------------------------------