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