Skip to content
Open
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
59 changes: 55 additions & 4 deletions custom_templates/auth/OAuthAuthenticator.mustache
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{{>partial_header}}

using System;
using System.Collections.Concurrent;
using System.Threading.Tasks;
using Newtonsoft.Json;
using RestSharp;
Expand All @@ -20,6 +21,25 @@ namespace {{packageName}}.Client.Auth
readonly JsonSerializerSettings _serializerSettings;
readonly IReadableConfiguration _configuration;

/// <summary>
/// Refresh slightly before the server-stated expiry so a token is never used in-flight as it expires.
/// </summary>
const int TokenExpiryBufferSeconds = 60;

/// <summary>
/// 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.
/// </summary>
static readonly ConcurrentDictionary<string, CachedToken> _tokenCache = new ConcurrentDictionary<string, CachedToken>();

sealed class CachedToken
{
public string Token { get; set; }
public DateTime ExpiresAtUtc { get; set; }
}

string CacheKey => $"{_tokenUrl}|{_clientId}";

/// <summary>
/// Initialize the OAuth2 Authenticator
/// </summary>
Expand Down Expand Up @@ -63,10 +83,24 @@ namespace {{packageName}}.Client.Auth
/// <returns>An authentication parameter.</returns>
protected override async ValueTask<Parameter> 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);
}

/// <summary>
/// Returns a cached, unexpired token when one is available; otherwise fetches a new one.
/// </summary>
/// <returns>An authentication token.</returns>
async Task<string> GetCachedOrFetchToken()
{
if (_tokenCache.TryGetValue(CacheKey, out var cached) && cached.ExpiresAtUtc > DateTime.UtcNow)
{
return cached.Token;
}

return await GetToken().ConfigureAwait(false);
}

/// <summary>
/// Gets the token from the OAuth2 server.
/// </summary>
Expand All @@ -81,17 +115,34 @@ namespace {{packageName}}.Client.Auth
.AddHeader("Authorization", $"Basic {credentials}")
.AddParameter("grant_type", _grantType, ParameterType.GetOrPost);
var response = await client.PostAsync<TokenResponse>(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;
}
}
}
16 changes: 16 additions & 0 deletions custom_templates/auth/TokenResponse.mustache
Original file line number Diff line number Diff line change
@@ -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; }
}
}
186 changes: 186 additions & 0 deletions src/Bandwidth.Standard.Test/Unit/Client/OAuthAuthenticatorTests.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Class for testing OAuthAuthenticator token handling and caching.
/// </summary>
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();
}

/// <summary>
/// A valid token (with expires_in) should be fetched once and reused on subsequent calls.
/// </summary>
[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);
}

/// <summary>
/// The token request should authenticate with the client credentials via HTTP Basic auth.
/// </summary>
[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);
}

/// <summary>
/// When the server omits expires_in, the token must not be cached (falls back to fetching per call).
/// </summary>
[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;
}

/// <summary>
/// Exposes the protected GetAuthenticationParameter as the resolved Authorization header value.
/// </summary>
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<string> GetAuthHeaderAsync()
{
var parameter = await GetAuthenticationParameter(string.Empty);
return parameter.Value?.ToString();
}
}
}
}
59 changes: 55 additions & 4 deletions src/Bandwidth.Standard/Client/Auth/OAuthAuthenticator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@


using System;
using System.Collections.Concurrent;
using System.Threading.Tasks;
using Newtonsoft.Json;
using RestSharp;
Expand All @@ -29,6 +30,25 @@ public class OAuthAuthenticator : AuthenticatorBase
readonly JsonSerializerSettings _serializerSettings;
readonly IReadableConfiguration _configuration;

/// <summary>
/// Refresh slightly before the server-stated expiry so a token is never used in-flight as it expires.
/// </summary>
const int TokenExpiryBufferSeconds = 60;

/// <summary>
/// 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.
/// </summary>
static readonly ConcurrentDictionary<string, CachedToken> _tokenCache = new ConcurrentDictionary<string, CachedToken>();

sealed class CachedToken
{
public string Token { get; set; }
public DateTime ExpiresAtUtc { get; set; }
}

string CacheKey => $"{_tokenUrl}|{_clientId}";

/// <summary>
/// Initialize the OAuth2 Authenticator
/// </summary>
Expand Down Expand Up @@ -72,10 +92,24 @@ public OAuthAuthenticator(
/// <returns>An authentication parameter.</returns>
protected override async ValueTask<Parameter> 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);
}

/// <summary>
/// Returns a cached, unexpired token when one is available; otherwise fetches a new one.
/// </summary>
/// <returns>An authentication token.</returns>
async Task<string> GetCachedOrFetchToken()
{
if (_tokenCache.TryGetValue(CacheKey, out var cached) && cached.ExpiresAtUtc > DateTime.UtcNow)
{
return cached.Token;
}

return await GetToken().ConfigureAwait(false);
}

/// <summary>
/// Gets the token from the OAuth2 server.
/// </summary>
Expand All @@ -90,17 +124,34 @@ async Task<string> GetToken()
.AddHeader("Authorization", $"Basic {credentials}")
.AddParameter("grant_type", _grantType, ParameterType.GetOrPost);
var response = await client.PostAsync<TokenResponse>(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;
}
}
}
Loading
Loading