diff --git a/custom_templates/auth/OAuthAuthenticator.mustache b/custom_templates/auth/OAuthAuthenticator.mustache
index 4faec1e0..f726c092 100644
--- a/custom_templates/auth/OAuthAuthenticator.mustache
+++ b/custom_templates/auth/OAuthAuthenticator.mustache
@@ -1,6 +1,7 @@
{{>partial_header}}
using System;
+using System.Collections.Concurrent;
using System.Threading.Tasks;
using Newtonsoft.Json;
using RestSharp;
@@ -20,6 +21,25 @@ namespace {{packageName}}.Client.Auth
readonly JsonSerializerSettings _serializerSettings;
readonly IReadableConfiguration _configuration;
+ ///
+ /// Refresh slightly before the server-stated expiry so a token is never used in-flight as it expires.
+ ///
+ const int TokenExpiryBufferSeconds = 60;
+
+ ///
+ /// Process-wide token cache, keyed by token URL + client id. A new OAuthAuthenticator is created per
+ /// request by the ApiClient, so the cache must be static for tokens to be reused across requests.
+ ///
+ static readonly ConcurrentDictionary _tokenCache = new ConcurrentDictionary();
+
+ sealed class CachedToken
+ {
+ public string Token { get; set; }
+ public DateTime ExpiresAtUtc { get; set; }
+ }
+
+ string CacheKey => $"{_tokenUrl}|{_clientId}";
+
///
/// Initialize the OAuth2 Authenticator
///
@@ -63,10 +83,24 @@ namespace {{packageName}}.Client.Auth
/// An authentication parameter.
protected override async ValueTask GetAuthenticationParameter(string accessToken)
{
- var token = string.IsNullOrEmpty(Token) ? await GetToken().ConfigureAwait(false) : Token;
+ var token = await GetCachedOrFetchToken().ConfigureAwait(false);
return new HeaderParameter(KnownHeaders.Authorization, token);
}
+ ///
+ /// Returns a cached, unexpired token when one is available; otherwise fetches a new one.
+ ///
+ /// An authentication token.
+ async Task GetCachedOrFetchToken()
+ {
+ if (_tokenCache.TryGetValue(CacheKey, out var cached) && cached.ExpiresAtUtc > DateTime.UtcNow)
+ {
+ return cached.Token;
+ }
+
+ return await GetToken().ConfigureAwait(false);
+ }
+
///
/// Gets the token from the OAuth2 server.
///
@@ -81,17 +115,34 @@ namespace {{packageName}}.Client.Auth
.AddHeader("Authorization", $"Basic {credentials}")
.AddParameter("grant_type", _grantType, ParameterType.GetOrPost);
var response = await client.PostAsync(request).ConfigureAwait(false);
-
+
// RFC6749 - token_type is case insensitive.
// RFC6750 - In Authorization header Bearer should be capitalized.
// Fix the capitalization irrespective of token_type casing.
+ string token;
switch (response.TokenType?.ToLower())
{
case "bearer":
- return $"Bearer {response.AccessToken}";
+ token = $"Bearer {response.AccessToken}";
+ break;
default:
- return $"{response.TokenType} {response.AccessToken}";
+ token = $"{response.TokenType} {response.AccessToken}";
+ break;
+ }
+
+ // Only cache when the server tells us how long the token is valid. Caching without a known
+ // expiry would risk serving a stale token indefinitely; when expires_in is absent we fall back
+ // to fetching per request (always valid, just not cached).
+ if (response.ExpiresIn > TokenExpiryBufferSeconds)
+ {
+ _tokenCache[CacheKey] = new CachedToken
+ {
+ Token = token,
+ ExpiresAtUtc = DateTime.UtcNow.AddSeconds(response.ExpiresIn - TokenExpiryBufferSeconds)
+ };
}
+
+ return token;
}
}
}
diff --git a/custom_templates/auth/TokenResponse.mustache b/custom_templates/auth/TokenResponse.mustache
new file mode 100644
index 00000000..f8b617e5
--- /dev/null
+++ b/custom_templates/auth/TokenResponse.mustache
@@ -0,0 +1,16 @@
+{{>partial_header}}
+
+using Newtonsoft.Json;
+
+namespace {{packageName}}.Client.Auth
+{
+ class TokenResponse
+ {
+ [JsonProperty("token_type")]
+ public string TokenType { get; set; }
+ [JsonProperty("access_token")]
+ public string AccessToken { get; set; }
+ [JsonProperty("expires_in")]
+ public int ExpiresIn { get; set; }
+ }
+}
diff --git a/src/Bandwidth.Standard.Test/Unit/Client/OAuthAuthenticatorTests.cs b/src/Bandwidth.Standard.Test/Unit/Client/OAuthAuthenticatorTests.cs
new file mode 100644
index 00000000..dd499d6c
--- /dev/null
+++ b/src/Bandwidth.Standard.Test/Unit/Client/OAuthAuthenticatorTests.cs
@@ -0,0 +1,186 @@
+/*
+ * Bandwidth
+ *
+ * Bandwidth's Communication APIs
+ *
+ * The version of the OpenAPI document: 1.0.0
+ * Contact: letstalk@bandwidth.com
+ * Generated by: https://github.com/openapitools/openapi-generator.git
+ */
+
+
+using System;
+using System.Net;
+using System.Net.Sockets;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Xunit;
+using Newtonsoft.Json;
+using Bandwidth.Standard.Client;
+using Bandwidth.Standard.Client.Auth;
+
+namespace Bandwidth.Standard.Test.Unit.Client
+{
+ ///
+ /// Class for testing OAuthAuthenticator token handling and caching.
+ ///
+ public class OAuthAuthenticatorTests : IDisposable
+ {
+ private readonly HttpListener _listener;
+ private readonly string _tokenUrl;
+ private readonly string _clientId;
+ private const string ClientSecret = "test-secret";
+ private const string AccessToken = "test-access-token";
+
+ // Each call to the fake token endpoint increments this so tests can assert how
+ // many times a token was actually fetched from the server.
+ private int _tokenRequestCount;
+ private string _lastAuthorizationHeader;
+
+ // Controls whether the fake endpoint returns expires_in; toggled per test.
+ private int _expiresInSeconds = 3600;
+ private bool _includeExpiresIn = true;
+
+ public OAuthAuthenticatorTests()
+ {
+ // Unique client id per test keeps the authenticator's process-wide static
+ // token cache isolated between tests (cache key is tokenUrl + clientId).
+ _clientId = "test-client-" + Guid.NewGuid().ToString("N");
+
+ int port = GetFreeTcpPort();
+ _tokenUrl = $"http://localhost:{port}/oauth2/token";
+
+ _listener = new HttpListener();
+ _listener.Prefixes.Add($"http://localhost:{port}/");
+ _listener.Start();
+ _ = Task.Run(ServeTokenRequestsAsync);
+ }
+
+ public void Dispose()
+ {
+ if (_listener.IsListening)
+ _listener.Stop();
+ _listener.Close();
+ }
+
+ ///
+ /// A valid token (with expires_in) should be fetched once and reused on subsequent calls.
+ ///
+ [Fact]
+ public async Task GetAuthenticationParameter_CachesToken_FetchesOnlyOnce()
+ {
+ var authenticator = CreateAuthenticator();
+
+ string first = await authenticator.GetAuthHeaderAsync();
+ string second = await authenticator.GetAuthHeaderAsync();
+
+ Assert.Equal($"Bearer {AccessToken}", first);
+ Assert.Equal(first, second);
+ Assert.Equal(1, _tokenRequestCount);
+ }
+
+ ///
+ /// The token request should authenticate with the client credentials via HTTP Basic auth.
+ ///
+ [Fact]
+ public async Task GetAuthenticationParameter_SendsClientCredentialsAsBasicAuth()
+ {
+ var authenticator = CreateAuthenticator();
+
+ await authenticator.GetAuthHeaderAsync();
+
+ string expected = "Basic " + Convert.ToBase64String(
+ Encoding.UTF8.GetBytes($"{_clientId}:{ClientSecret}"));
+ Assert.Equal(expected, _lastAuthorizationHeader);
+ }
+
+ ///
+ /// When the server omits expires_in, the token must not be cached (falls back to fetching per call).
+ ///
+ [Fact]
+ public async Task GetAuthenticationParameter_WithoutExpiresIn_FetchesEachTime()
+ {
+ _includeExpiresIn = false;
+ var authenticator = CreateAuthenticator();
+
+ await authenticator.GetAuthHeaderAsync();
+ await authenticator.GetAuthHeaderAsync();
+
+ Assert.Equal(2, _tokenRequestCount);
+ }
+
+ private TestableOAuthAuthenticator CreateAuthenticator()
+ {
+ return new TestableOAuthAuthenticator(
+ _tokenUrl,
+ _clientId,
+ ClientSecret,
+ OAuthFlow.APPLICATION,
+ new JsonSerializerSettings(),
+ new Configuration());
+ }
+
+ private async Task ServeTokenRequestsAsync()
+ {
+ while (_listener.IsListening)
+ {
+ HttpListenerContext context;
+ try
+ {
+ context = await _listener.GetContextAsync();
+ }
+ catch
+ {
+ // Listener stopped/disposed during teardown.
+ break;
+ }
+
+ Interlocked.Increment(ref _tokenRequestCount);
+ _lastAuthorizationHeader = context.Request.Headers["Authorization"];
+
+ string body = _includeExpiresIn
+ ? $"{{\"token_type\":\"Bearer\",\"access_token\":\"{AccessToken}\",\"expires_in\":{_expiresInSeconds}}}"
+ : $"{{\"token_type\":\"Bearer\",\"access_token\":\"{AccessToken}\"}}";
+
+ byte[] bytes = Encoding.UTF8.GetBytes(body);
+ context.Response.ContentType = "application/json";
+ context.Response.ContentLength64 = bytes.Length;
+ await context.Response.OutputStream.WriteAsync(bytes, 0, bytes.Length);
+ context.Response.Close();
+ }
+ }
+
+ private static int GetFreeTcpPort()
+ {
+ var listener = new TcpListener(IPAddress.Loopback, 0);
+ listener.Start();
+ int port = ((IPEndPoint)listener.LocalEndpoint).Port;
+ listener.Stop();
+ return port;
+ }
+
+ ///
+ /// Exposes the protected GetAuthenticationParameter as the resolved Authorization header value.
+ ///
+ private sealed class TestableOAuthAuthenticator : OAuthAuthenticator
+ {
+ public TestableOAuthAuthenticator(
+ string tokenUrl,
+ string clientId,
+ string clientSecret,
+ OAuthFlow? flow,
+ JsonSerializerSettings serializerSettings,
+ IReadableConfiguration configuration)
+ : base(tokenUrl, clientId, clientSecret, flow, serializerSettings, configuration)
+ {
+ }
+
+ public async Task GetAuthHeaderAsync()
+ {
+ var parameter = await GetAuthenticationParameter(string.Empty);
+ return parameter.Value?.ToString();
+ }
+ }
+ }
+}
diff --git a/src/Bandwidth.Standard/Client/Auth/OAuthAuthenticator.cs b/src/Bandwidth.Standard/Client/Auth/OAuthAuthenticator.cs
index 4217b129..9c9da17c 100644
--- a/src/Bandwidth.Standard/Client/Auth/OAuthAuthenticator.cs
+++ b/src/Bandwidth.Standard/Client/Auth/OAuthAuthenticator.cs
@@ -10,6 +10,7 @@
using System;
+using System.Collections.Concurrent;
using System.Threading.Tasks;
using Newtonsoft.Json;
using RestSharp;
@@ -29,6 +30,25 @@ public class OAuthAuthenticator : AuthenticatorBase
readonly JsonSerializerSettings _serializerSettings;
readonly IReadableConfiguration _configuration;
+ ///
+ /// Refresh slightly before the server-stated expiry so a token is never used in-flight as it expires.
+ ///
+ const int TokenExpiryBufferSeconds = 60;
+
+ ///
+ /// Process-wide token cache, keyed by token URL + client id. A new OAuthAuthenticator is created per
+ /// request by the ApiClient, so the cache must be static for tokens to be reused across requests.
+ ///
+ static readonly ConcurrentDictionary _tokenCache = new ConcurrentDictionary();
+
+ sealed class CachedToken
+ {
+ public string Token { get; set; }
+ public DateTime ExpiresAtUtc { get; set; }
+ }
+
+ string CacheKey => $"{_tokenUrl}|{_clientId}";
+
///
/// Initialize the OAuth2 Authenticator
///
@@ -72,10 +92,24 @@ public OAuthAuthenticator(
/// An authentication parameter.
protected override async ValueTask GetAuthenticationParameter(string accessToken)
{
- var token = string.IsNullOrEmpty(Token) ? await GetToken().ConfigureAwait(false) : Token;
+ var token = await GetCachedOrFetchToken().ConfigureAwait(false);
return new HeaderParameter(KnownHeaders.Authorization, token);
}
+ ///
+ /// Returns a cached, unexpired token when one is available; otherwise fetches a new one.
+ ///
+ /// An authentication token.
+ async Task GetCachedOrFetchToken()
+ {
+ if (_tokenCache.TryGetValue(CacheKey, out var cached) && cached.ExpiresAtUtc > DateTime.UtcNow)
+ {
+ return cached.Token;
+ }
+
+ return await GetToken().ConfigureAwait(false);
+ }
+
///
/// Gets the token from the OAuth2 server.
///
@@ -90,17 +124,34 @@ async Task GetToken()
.AddHeader("Authorization", $"Basic {credentials}")
.AddParameter("grant_type", _grantType, ParameterType.GetOrPost);
var response = await client.PostAsync(request).ConfigureAwait(false);
-
+
// RFC6749 - token_type is case insensitive.
// RFC6750 - In Authorization header Bearer should be capitalized.
// Fix the capitalization irrespective of token_type casing.
+ string token;
switch (response.TokenType?.ToLower())
{
case "bearer":
- return $"Bearer {response.AccessToken}";
+ token = $"Bearer {response.AccessToken}";
+ break;
default:
- return $"{response.TokenType} {response.AccessToken}";
+ token = $"{response.TokenType} {response.AccessToken}";
+ break;
+ }
+
+ // Only cache when the server tells us how long the token is valid. Caching without a known
+ // expiry would risk serving a stale token indefinitely; when expires_in is absent we fall back
+ // to fetching per request (always valid, just not cached).
+ if (response.ExpiresIn > TokenExpiryBufferSeconds)
+ {
+ _tokenCache[CacheKey] = new CachedToken
+ {
+ Token = token,
+ ExpiresAtUtc = DateTime.UtcNow.AddSeconds(response.ExpiresIn - TokenExpiryBufferSeconds)
+ };
}
+
+ return token;
}
}
}
diff --git a/src/Bandwidth.Standard/Client/Auth/TokenResponse.cs b/src/Bandwidth.Standard/Client/Auth/TokenResponse.cs
index 56e84de6..8707570e 100644
--- a/src/Bandwidth.Standard/Client/Auth/TokenResponse.cs
+++ b/src/Bandwidth.Standard/Client/Auth/TokenResponse.cs
@@ -19,5 +19,7 @@ class TokenResponse
public string TokenType { get; set; }
[JsonProperty("access_token")]
public string AccessToken { get; set; }
+ [JsonProperty("expires_in")]
+ public int ExpiresIn { get; set; }
}
}
\ No newline at end of file