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));
+ }
+ }
+}