Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions src/Dexpace.Sdk.Core/Auth/AccessToken.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// An access token returned by a <see cref="TokenCredential"/>, together with its expiry
/// and an optional proactive-refresh hint.
/// </summary>
public readonly struct AccessToken
{
/// <summary>
/// Initializes an <see cref="AccessToken"/> with an expiry and no proactive-refresh hint.
/// </summary>
/// <param name="token">The raw token string.</param>
/// <param name="expiresOn">The time at which this token becomes invalid.</param>
/// <exception cref="ArgumentNullException"><paramref name="token"/> is <see langword="null"/>.</exception>
public AccessToken(string token, DateTimeOffset expiresOn)
: this(token, expiresOn, null)
{
}

/// <summary>
/// Initializes an <see cref="AccessToken"/> with an expiry and an optional proactive-refresh hint.
/// </summary>
/// <param name="token">The raw token string.</param>
/// <param name="expiresOn">The time at which this token becomes invalid.</param>
/// <param name="refreshOn">
/// An optional hint: when the clock reaches this value the token cache should proactively
/// refresh, even though <paramref name="expiresOn"/> has not been reached.
/// </param>
/// <exception cref="ArgumentNullException"><paramref name="token"/> is <see langword="null"/>.</exception>
public AccessToken(string token, DateTimeOffset expiresOn, DateTimeOffset? refreshOn)
{
ArgumentNullException.ThrowIfNull(token);
Token = token;
ExpiresOn = expiresOn;
RefreshOn = refreshOn;
}

/// <summary>The raw token value.</summary>
public string Token { get; }

/// <summary>The time at which this token becomes invalid.</summary>
public DateTimeOffset ExpiresOn { get; }

/// <summary>
/// An optional proactive-refresh hint. When non-<see langword="null"/>, the token cache
/// begins refreshing once the clock passes this value, even though <see cref="ExpiresOn"/>
/// has not yet been reached.
/// </summary>
public DateTimeOffset? RefreshOn { get; }
}
172 changes: 172 additions & 0 deletions src/Dexpace.Sdk.Core/Auth/AccessTokenCache.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// An in-memory token cache that wraps a <see cref="TokenCredential"/> and provides
/// proactive refresh, expiry-based invalidation, and single-flight protection against
/// concurrent refresh stampedes.
/// </summary>
/// <remarks>
/// <para>
/// Tokens are cached keyed by the <see cref="TokenRequestContext.CacheKey"/>. A cached token
/// is served without calling the underlying credential as long as both:
/// <list type="bullet">
/// <item><c>now &lt; ExpiresOn</c></item>
/// <item><c>RefreshOn</c> is <see langword="null"/> OR <c>now &lt; RefreshOn</c></item>
/// </list>
/// </para>
/// <para>
/// 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.
/// </para>
/// <para>
/// <strong>Failure while valid:</strong> if the credential throws but a token remains valid
/// (<c>now &lt; ExpiresOn</c>), the cached token is returned silently. If no valid token
/// exists the exception propagates.
/// </para>
/// <para>
/// This class is thread-safe. Inject a custom <see cref="TimeProvider"/> for deterministic
/// unit testing.
/// </para>
/// </remarks>
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<string, CacheEntry> _entries = new();

/// <summary>
/// Initializes an <see cref="AccessTokenCache"/> backed by the given credential.
/// </summary>
/// <param name="credential">The underlying credential to call when a token is needed.</param>
/// <param name="timeProvider">
/// A <see cref="TimeProvider"/> used to determine "now". Defaults to
/// <see cref="TimeProvider.System"/> when <see langword="null"/>.
/// </param>
/// <exception cref="ArgumentNullException"><paramref name="credential"/> is <see langword="null"/>.</exception>
public AccessTokenCache(TokenCredential credential, TimeProvider? timeProvider = null)
{
ArgumentNullException.ThrowIfNull(credential);
_credential = credential;
_time = timeProvider ?? TimeProvider.System;
}

/// <summary>
/// Returns a valid <see cref="AccessToken"/> for the given <paramref name="context"/>,
/// fetching and caching one if necessary.
/// </summary>
/// <param name="context">The token request context identifying the scopes and claims.</param>
/// <param name="ct">A token to cancel an in-progress credential call.</param>
/// <returns>
/// A <see cref="ValueTask{AccessToken}"/> resolving to a valid access token.
/// </returns>
public async ValueTask<AccessToken> 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
{
/// <summary>Initializes a <see cref="TokenHolder"/> wrapping the given token.</summary>
/// <param name="token">The token to publish atomically.</param>
public TokenHolder(AccessToken token) => Token = token;

/// <summary>The wrapped access token.</summary>
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;

/// <summary>
/// The most recently published token holder, or <see langword="null"/> if no token
/// has been fetched yet. Reads and writes are reference-atomic and carry
/// acquire/release ordering via the <see langword="volatile"/> modifier.
/// </summary>
public TokenHolder? Holder
{
get => _holder;
set => _holder = value;
}

/// <summary>
/// A semaphore (initial count = 1) that serializes refresh calls for this key.
/// </summary>
public SemaphoreSlim Semaphore { get; } = new SemaphoreSlim(1, 1);
}
}
56 changes: 56 additions & 0 deletions src/Dexpace.Sdk.Core/Auth/ApiKeyCredential.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// A credential that authenticates requests by stamping a static API key into an HTTP header.
/// </summary>
/// <remarks>
/// By default the key is sent in the <c>Authorization</c> header with no scheme prefix, i.e.
/// the header value is exactly <see cref="Key"/>. Specify a scheme to add a prefix, e.g.
/// <c>"Bearer"</c> produces <c>Authorization: Bearer &lt;key&gt;</c>. Pass a custom header
/// name to use a non-standard header such as <c>X-Api-Key</c>.
/// </remarks>
public sealed class ApiKeyCredential
{
/// <summary>
/// Initializes an <see cref="ApiKeyCredential"/>.
/// </summary>
/// <param name="key">The API key value. Must not be null or empty.</param>
/// <param name="header">
/// The header to stamp. Defaults to <see cref="HttpHeaderName.WellKnown.Authorization"/>.
/// </param>
/// <param name="scheme">
/// An optional scheme prefix (e.g. <c>"Bearer"</c>). When <see langword="null"/> the key
/// is used as the entire header value.
/// </param>
/// <exception cref="ArgumentNullException"><paramref name="key"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException"><paramref name="key"/> is empty.</exception>
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;
}

/// <summary>The raw API key value.</summary>
public string Key { get; }

/// <summary>The HTTP header into which the key is stamped.</summary>
public HttpHeaderName HeaderName { get; }

/// <summary>
/// The optional scheme prefix. When non-<see langword="null"/>, the header value is
/// <c>"&lt;Scheme&gt; &lt;Key&gt;"</c>; otherwise the header value is exactly <see cref="Key"/>.
/// </summary>
public string? Scheme { get; }
}
50 changes: 50 additions & 0 deletions src/Dexpace.Sdk.Core/Auth/BasicCredential.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// A credential that authenticates requests using the HTTP Basic scheme (RFC 7617).
/// </summary>
/// <remarks>
/// The credential stores the username and password in plain text. Call <see cref="ToBase64"/>
/// to obtain the Base64-encoded <c>username:password</c> token suitable for the
/// <c>Authorization: Basic &lt;token&gt;</c> header value.
/// </remarks>
public sealed class BasicCredential
{
/// <summary>
/// Initializes a <see cref="BasicCredential"/> with the given username and password.
/// </summary>
/// <param name="username">The username. Must not be <see langword="null"/>.</param>
/// <param name="password">The password. Must not be <see langword="null"/>.</param>
/// <exception cref="ArgumentNullException">
/// <paramref name="username"/> or <paramref name="password"/> is <see langword="null"/>.
/// </exception>
public BasicCredential(string username, string password)
{
ArgumentNullException.ThrowIfNull(username);
ArgumentNullException.ThrowIfNull(password);
Username = username;
Password = password;
}

/// <summary>The username.</summary>
public string Username { get; }

/// <summary>The password.</summary>
public string Password { get; }

/// <summary>
/// Returns the Base64-encoded UTF-8 <c>username:password</c> token for use in the
/// <c>Authorization: Basic &lt;token&gt;</c> header value.
/// </summary>
/// <returns>The Base64-encoded credentials.</returns>
public string ToBase64()
{
var bytes = Encoding.UTF8.GetBytes($"{Username}:{Password}");
return Convert.ToBase64String(bytes);
}
}
48 changes: 48 additions & 0 deletions src/Dexpace.Sdk.Core/Auth/TokenCredential.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Base class for credential implementations that produce <see cref="AccessToken"/> instances.
/// </summary>
/// <remarks>
/// <para>
/// Subclasses implement <see cref="GetTokenAsync"/> and may optionally override
/// <see cref="GetToken"/> for a non-blocking synchronous path. The default
/// <see cref="GetToken"/> implementation is a blocking bridge over
/// <see cref="GetTokenAsync"/>; override it when a truly synchronous code path exists.
/// </para>
/// <para>
/// Tokens are typically obtained through an <c>AccessTokenCache</c> rather than calling
/// this type directly, so that caching, proactive refresh, and single-flight behaviour are
/// applied automatically.
/// </para>
/// </remarks>
public abstract class TokenCredential
{
/// <summary>
/// Asynchronously obtains an <see cref="AccessToken"/> for the requested context.
/// </summary>
/// <param name="context">The scopes and optional claims for the token request.</param>
/// <param name="ct">A token to cancel the request.</param>
/// <returns>
/// A <see cref="ValueTask{AccessToken}"/> that resolves to the token on success.
/// </returns>
public abstract ValueTask<AccessToken> GetTokenAsync(
TokenRequestContext context,
CancellationToken ct = default);

/// <summary>
/// Synchronously obtains an <see cref="AccessToken"/> for the requested context.
/// </summary>
/// <remarks>
/// The default implementation is a blocking bridge over <see cref="GetTokenAsync"/>.
/// Override this method when a non-blocking synchronous path is available.
/// </remarks>
/// <param name="context">The scopes and optional claims for the token request.</param>
/// <param name="ct">A token to cancel the request.</param>
/// <returns>The access token.</returns>
public virtual AccessToken GetToken(TokenRequestContext context, CancellationToken ct = default)
=> GetTokenAsync(context, ct).AsTask().GetAwaiter().GetResult();
}
Loading
Loading