Skip to content

Commit ab58bb8

Browse files
authored
feat: add authentication credentials, token cache, and auth policies (#8)
PR: #8
1 parent ac1439b commit ab58bb8

15 files changed

Lines changed: 2089 additions & 0 deletions
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// Copyright (c) 2026 dexpace and Omar Aljarrah.
2+
// Licensed under the MIT License. See LICENSE in the repository root for details.
3+
4+
namespace Dexpace.Sdk.Core.Auth;
5+
6+
/// <summary>
7+
/// An access token returned by a <see cref="TokenCredential"/>, together with its expiry
8+
/// and an optional proactive-refresh hint.
9+
/// </summary>
10+
public readonly struct AccessToken
11+
{
12+
/// <summary>
13+
/// Initializes an <see cref="AccessToken"/> with an expiry and no proactive-refresh hint.
14+
/// </summary>
15+
/// <param name="token">The raw token string.</param>
16+
/// <param name="expiresOn">The time at which this token becomes invalid.</param>
17+
/// <exception cref="ArgumentNullException"><paramref name="token"/> is <see langword="null"/>.</exception>
18+
public AccessToken(string token, DateTimeOffset expiresOn)
19+
: this(token, expiresOn, null)
20+
{
21+
}
22+
23+
/// <summary>
24+
/// Initializes an <see cref="AccessToken"/> with an expiry and an optional proactive-refresh hint.
25+
/// </summary>
26+
/// <param name="token">The raw token string.</param>
27+
/// <param name="expiresOn">The time at which this token becomes invalid.</param>
28+
/// <param name="refreshOn">
29+
/// An optional hint: when the clock reaches this value the token cache should proactively
30+
/// refresh, even though <paramref name="expiresOn"/> has not been reached.
31+
/// </param>
32+
/// <exception cref="ArgumentNullException"><paramref name="token"/> is <see langword="null"/>.</exception>
33+
public AccessToken(string token, DateTimeOffset expiresOn, DateTimeOffset? refreshOn)
34+
{
35+
ArgumentNullException.ThrowIfNull(token);
36+
Token = token;
37+
ExpiresOn = expiresOn;
38+
RefreshOn = refreshOn;
39+
}
40+
41+
/// <summary>The raw token value.</summary>
42+
public string Token { get; }
43+
44+
/// <summary>The time at which this token becomes invalid.</summary>
45+
public DateTimeOffset ExpiresOn { get; }
46+
47+
/// <summary>
48+
/// An optional proactive-refresh hint. When non-<see langword="null"/>, the token cache
49+
/// begins refreshing once the clock passes this value, even though <see cref="ExpiresOn"/>
50+
/// has not yet been reached.
51+
/// </summary>
52+
public DateTimeOffset? RefreshOn { get; }
53+
}
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
// Copyright (c) 2026 dexpace and Omar Aljarrah.
2+
// Licensed under the MIT License. See LICENSE in the repository root for details.
3+
4+
namespace Dexpace.Sdk.Core.Auth;
5+
6+
/// <summary>
7+
/// An in-memory token cache that wraps a <see cref="TokenCredential"/> and provides
8+
/// proactive refresh, expiry-based invalidation, and single-flight protection against
9+
/// concurrent refresh stampedes.
10+
/// </summary>
11+
/// <remarks>
12+
/// <para>
13+
/// Tokens are cached keyed by the <see cref="TokenRequestContext.CacheKey"/>. A cached token
14+
/// is served without calling the underlying credential as long as both:
15+
/// <list type="bullet">
16+
/// <item><c>now &lt; ExpiresOn</c></item>
17+
/// <item><c>RefreshOn</c> is <see langword="null"/> OR <c>now &lt; RefreshOn</c></item>
18+
/// </list>
19+
/// </para>
20+
/// <para>
21+
/// Once either condition fails, a single caller acquires the per-key semaphore and calls
22+
/// the credential. All concurrent callers wait on the semaphore and perform a double-checked
23+
/// read after acquiring it. Only one network round-trip fires per key per refresh cycle.
24+
/// </para>
25+
/// <para>
26+
/// <strong>Failure while valid:</strong> if the credential throws but a token remains valid
27+
/// (<c>now &lt; ExpiresOn</c>), the cached token is returned silently. If no valid token
28+
/// exists the exception propagates.
29+
/// </para>
30+
/// <para>
31+
/// This class is thread-safe. Inject a custom <see cref="TimeProvider"/> for deterministic
32+
/// unit testing.
33+
/// </para>
34+
/// </remarks>
35+
public sealed class AccessTokenCache
36+
{
37+
private readonly TokenCredential _credential;
38+
private readonly TimeProvider _time;
39+
40+
// Per-key state: the current cached token (null = never fetched) + a semaphore for
41+
// single-flight. One CacheEntry per unique TokenRequestContext.CacheKey.
42+
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, CacheEntry> _entries = new();
43+
44+
/// <summary>
45+
/// Initializes an <see cref="AccessTokenCache"/> backed by the given credential.
46+
/// </summary>
47+
/// <param name="credential">The underlying credential to call when a token is needed.</param>
48+
/// <param name="timeProvider">
49+
/// A <see cref="TimeProvider"/> used to determine "now". Defaults to
50+
/// <see cref="TimeProvider.System"/> when <see langword="null"/>.
51+
/// </param>
52+
/// <exception cref="ArgumentNullException"><paramref name="credential"/> is <see langword="null"/>.</exception>
53+
public AccessTokenCache(TokenCredential credential, TimeProvider? timeProvider = null)
54+
{
55+
ArgumentNullException.ThrowIfNull(credential);
56+
_credential = credential;
57+
_time = timeProvider ?? TimeProvider.System;
58+
}
59+
60+
/// <summary>
61+
/// Returns a valid <see cref="AccessToken"/> for the given <paramref name="context"/>,
62+
/// fetching and caching one if necessary.
63+
/// </summary>
64+
/// <param name="context">The token request context identifying the scopes and claims.</param>
65+
/// <param name="ct">A token to cancel an in-progress credential call.</param>
66+
/// <returns>
67+
/// A <see cref="ValueTask{AccessToken}"/> resolving to a valid access token.
68+
/// </returns>
69+
public async ValueTask<AccessToken> GetAsync(TokenRequestContext context, CancellationToken ct = default)
70+
{
71+
var entry = _entries.GetOrAdd(context.CacheKey, static _ => new CacheEntry());
72+
var now = _time.GetUtcNow();
73+
74+
// Fast path: read the holder once (volatile acquire) and return immediately when the
75+
// token is still valid and does not need a proactive refresh. The volatile read ensures
76+
// that any holder published by a slow-path writer is fully visible to this thread.
77+
var fastHolder = entry.Holder;
78+
if (fastHolder is not null && IsValid(fastHolder.Token, now) && !NeedsRefresh(fastHolder.Token, now))
79+
{
80+
return fastHolder.Token;
81+
}
82+
83+
// Slow path: acquire the semaphore for this key.
84+
await entry.Semaphore.WaitAsync(ct).ConfigureAwait(false);
85+
try
86+
{
87+
// Double-check after acquiring.
88+
now = _time.GetUtcNow();
89+
var recheckedHolder = entry.Holder;
90+
if (recheckedHolder is not null && IsValid(recheckedHolder.Token, now) && !NeedsRefresh(recheckedHolder.Token, now))
91+
{
92+
return recheckedHolder.Token;
93+
}
94+
95+
// Try to refresh from the credential.
96+
try
97+
{
98+
var fresh = await _credential.GetTokenAsync(context, ct).ConfigureAwait(false);
99+
// Volatile write releases the fully-constructed holder to all threads.
100+
entry.Holder = new TokenHolder(fresh);
101+
return fresh;
102+
}
103+
catch
104+
{
105+
// Re-sample time: GetTokenAsync may have taken a long time. Validate the
106+
// cached token against the clock *after* the (slow) failing call, so a token
107+
// that expired during the attempt is not returned as valid.
108+
var nowAfterFailure = _time.GetUtcNow();
109+
var fallbackHolder = entry.Holder;
110+
if (fallbackHolder is not null && IsValid(fallbackHolder.Token, nowAfterFailure))
111+
{
112+
return fallbackHolder.Token;
113+
}
114+
115+
throw;
116+
}
117+
}
118+
finally
119+
{
120+
entry.Semaphore.Release();
121+
}
122+
}
123+
124+
// A token is "valid" while the clock hasn't yet reached ExpiresOn.
125+
private static bool IsValid(AccessToken token, DateTimeOffset now) =>
126+
now < token.ExpiresOn;
127+
128+
// A token "needs refresh" once RefreshOn has been reached (proactive hint).
129+
private static bool NeedsRefresh(AccessToken token, DateTimeOffset now) =>
130+
token.RefreshOn is { } refreshOn && now >= refreshOn;
131+
132+
// Immutable wrapper so the token can be published through a single volatile reference,
133+
// giving acquire/release ordering on all platforms. AccessToken is a multi-field struct;
134+
// publishing it directly would allow fast-path readers to observe a torn or partially
135+
// written value. Boxing it inside a reference type (TokenHolder) reduces the published
136+
// value to a single pointer-width write, which the .NET memory model guarantees to be
137+
// atomic and not torn.
138+
private sealed class TokenHolder
139+
{
140+
/// <summary>Initializes a <see cref="TokenHolder"/> wrapping the given token.</summary>
141+
/// <param name="token">The token to publish atomically.</param>
142+
public TokenHolder(AccessToken token) => Token = token;
143+
144+
/// <summary>The wrapped access token.</summary>
145+
public AccessToken Token { get; }
146+
}
147+
148+
private sealed class CacheEntry
149+
{
150+
// volatile ensures that a fast-path reader always observes the most recently published
151+
// holder (acquire semantics on read, release semantics on write). Combined with the
152+
// pointer-width atomicity of reference reads/writes, this is safe without a lock on
153+
// the fast path.
154+
private volatile TokenHolder? _holder;
155+
156+
/// <summary>
157+
/// The most recently published token holder, or <see langword="null"/> if no token
158+
/// has been fetched yet. Reads and writes are reference-atomic and carry
159+
/// acquire/release ordering via the <see langword="volatile"/> modifier.
160+
/// </summary>
161+
public TokenHolder? Holder
162+
{
163+
get => _holder;
164+
set => _holder = value;
165+
}
166+
167+
/// <summary>
168+
/// A semaphore (initial count = 1) that serializes refresh calls for this key.
169+
/// </summary>
170+
public SemaphoreSlim Semaphore { get; } = new SemaphoreSlim(1, 1);
171+
}
172+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// Copyright (c) 2026 dexpace and Omar Aljarrah.
2+
// Licensed under the MIT License. See LICENSE in the repository root for details.
3+
4+
using Dexpace.Sdk.Core.Http.Common;
5+
6+
namespace Dexpace.Sdk.Core.Auth;
7+
8+
/// <summary>
9+
/// A credential that authenticates requests by stamping a static API key into an HTTP header.
10+
/// </summary>
11+
/// <remarks>
12+
/// By default the key is sent in the <c>Authorization</c> header with no scheme prefix, i.e.
13+
/// the header value is exactly <see cref="Key"/>. Specify a scheme to add a prefix, e.g.
14+
/// <c>"Bearer"</c> produces <c>Authorization: Bearer &lt;key&gt;</c>. Pass a custom header
15+
/// name to use a non-standard header such as <c>X-Api-Key</c>.
16+
/// </remarks>
17+
public sealed class ApiKeyCredential
18+
{
19+
/// <summary>
20+
/// Initializes an <see cref="ApiKeyCredential"/>.
21+
/// </summary>
22+
/// <param name="key">The API key value. Must not be null or empty.</param>
23+
/// <param name="header">
24+
/// The header to stamp. Defaults to <see cref="HttpHeaderName.WellKnown.Authorization"/>.
25+
/// </param>
26+
/// <param name="scheme">
27+
/// An optional scheme prefix (e.g. <c>"Bearer"</c>). When <see langword="null"/> the key
28+
/// is used as the entire header value.
29+
/// </param>
30+
/// <exception cref="ArgumentNullException"><paramref name="key"/> is <see langword="null"/>.</exception>
31+
/// <exception cref="ArgumentException"><paramref name="key"/> is empty.</exception>
32+
public ApiKeyCredential(string key, HttpHeaderName? header = null, string? scheme = null)
33+
{
34+
ArgumentNullException.ThrowIfNull(key);
35+
if (key.Length == 0)
36+
{
37+
throw new ArgumentException("API key must not be empty.", nameof(key));
38+
}
39+
40+
Key = key;
41+
HeaderName = header ?? HttpHeaderName.WellKnown.Authorization;
42+
Scheme = scheme;
43+
}
44+
45+
/// <summary>The raw API key value.</summary>
46+
public string Key { get; }
47+
48+
/// <summary>The HTTP header into which the key is stamped.</summary>
49+
public HttpHeaderName HeaderName { get; }
50+
51+
/// <summary>
52+
/// The optional scheme prefix. When non-<see langword="null"/>, the header value is
53+
/// <c>"&lt;Scheme&gt; &lt;Key&gt;"</c>; otherwise the header value is exactly <see cref="Key"/>.
54+
/// </summary>
55+
public string? Scheme { get; }
56+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// Copyright (c) 2026 dexpace and Omar Aljarrah.
2+
// Licensed under the MIT License. See LICENSE in the repository root for details.
3+
4+
using System.Text;
5+
6+
namespace Dexpace.Sdk.Core.Auth;
7+
8+
/// <summary>
9+
/// A credential that authenticates requests using the HTTP Basic scheme (RFC 7617).
10+
/// </summary>
11+
/// <remarks>
12+
/// The credential stores the username and password in plain text. Call <see cref="ToBase64"/>
13+
/// to obtain the Base64-encoded <c>username:password</c> token suitable for the
14+
/// <c>Authorization: Basic &lt;token&gt;</c> header value.
15+
/// </remarks>
16+
public sealed class BasicCredential
17+
{
18+
/// <summary>
19+
/// Initializes a <see cref="BasicCredential"/> with the given username and password.
20+
/// </summary>
21+
/// <param name="username">The username. Must not be <see langword="null"/>.</param>
22+
/// <param name="password">The password. Must not be <see langword="null"/>.</param>
23+
/// <exception cref="ArgumentNullException">
24+
/// <paramref name="username"/> or <paramref name="password"/> is <see langword="null"/>.
25+
/// </exception>
26+
public BasicCredential(string username, string password)
27+
{
28+
ArgumentNullException.ThrowIfNull(username);
29+
ArgumentNullException.ThrowIfNull(password);
30+
Username = username;
31+
Password = password;
32+
}
33+
34+
/// <summary>The username.</summary>
35+
public string Username { get; }
36+
37+
/// <summary>The password.</summary>
38+
public string Password { get; }
39+
40+
/// <summary>
41+
/// Returns the Base64-encoded UTF-8 <c>username:password</c> token for use in the
42+
/// <c>Authorization: Basic &lt;token&gt;</c> header value.
43+
/// </summary>
44+
/// <returns>The Base64-encoded credentials.</returns>
45+
public string ToBase64()
46+
{
47+
var bytes = Encoding.UTF8.GetBytes($"{Username}:{Password}");
48+
return Convert.ToBase64String(bytes);
49+
}
50+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// Copyright (c) 2026 dexpace and Omar Aljarrah.
2+
// Licensed under the MIT License. See LICENSE in the repository root for details.
3+
4+
namespace Dexpace.Sdk.Core.Auth;
5+
6+
/// <summary>
7+
/// Base class for credential implementations that produce <see cref="AccessToken"/> instances.
8+
/// </summary>
9+
/// <remarks>
10+
/// <para>
11+
/// Subclasses implement <see cref="GetTokenAsync"/> and may optionally override
12+
/// <see cref="GetToken"/> for a non-blocking synchronous path. The default
13+
/// <see cref="GetToken"/> implementation is a blocking bridge over
14+
/// <see cref="GetTokenAsync"/>; override it when a truly synchronous code path exists.
15+
/// </para>
16+
/// <para>
17+
/// Tokens are typically obtained through an <c>AccessTokenCache</c> rather than calling
18+
/// this type directly, so that caching, proactive refresh, and single-flight behaviour are
19+
/// applied automatically.
20+
/// </para>
21+
/// </remarks>
22+
public abstract class TokenCredential
23+
{
24+
/// <summary>
25+
/// Asynchronously obtains an <see cref="AccessToken"/> for the requested context.
26+
/// </summary>
27+
/// <param name="context">The scopes and optional claims for the token request.</param>
28+
/// <param name="ct">A token to cancel the request.</param>
29+
/// <returns>
30+
/// A <see cref="ValueTask{AccessToken}"/> that resolves to the token on success.
31+
/// </returns>
32+
public abstract ValueTask<AccessToken> GetTokenAsync(
33+
TokenRequestContext context,
34+
CancellationToken ct = default);
35+
36+
/// <summary>
37+
/// Synchronously obtains an <see cref="AccessToken"/> for the requested context.
38+
/// </summary>
39+
/// <remarks>
40+
/// The default implementation is a blocking bridge over <see cref="GetTokenAsync"/>.
41+
/// Override this method when a non-blocking synchronous path is available.
42+
/// </remarks>
43+
/// <param name="context">The scopes and optional claims for the token request.</param>
44+
/// <param name="ct">A token to cancel the request.</param>
45+
/// <returns>The access token.</returns>
46+
public virtual AccessToken GetToken(TokenRequestContext context, CancellationToken ct = default)
47+
=> GetTokenAsync(context, ct).AsTask().GetAwaiter().GetResult();
48+
}

0 commit comments

Comments
 (0)