Skip to content

Commit 27e2382

Browse files
SWI-11551 Fix OAuth to Cache Valid Token (#196)
* Generate SDK with OpenAPI Generator Version * potential oauth fixes * ignore op server * add test * Revert "ignore op server" This reverts commit 0d8b606. --------- Co-authored-by: DX-Bandwidth <dx@bandwidth.com>
1 parent 777c42f commit 27e2382

5 files changed

Lines changed: 314 additions & 8 deletions

File tree

custom_templates/auth/OAuthAuthenticator.mustache

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{{>partial_header}}
22

33
using System;
4+
using System.Collections.Concurrent;
45
using System.Threading.Tasks;
56
using Newtonsoft.Json;
67
using RestSharp;
@@ -20,6 +21,25 @@ namespace {{packageName}}.Client.Auth
2021
readonly JsonSerializerSettings _serializerSettings;
2122
readonly IReadableConfiguration _configuration;
2223
24+
/// <summary>
25+
/// Refresh slightly before the server-stated expiry so a token is never used in-flight as it expires.
26+
/// </summary>
27+
const int TokenExpiryBufferSeconds = 60;
28+
29+
/// <summary>
30+
/// Process-wide token cache, keyed by token URL + client id. A new OAuthAuthenticator is created per
31+
/// request by the ApiClient, so the cache must be static for tokens to be reused across requests.
32+
/// </summary>
33+
static readonly ConcurrentDictionary<string, CachedToken> _tokenCache = new ConcurrentDictionary<string, CachedToken>();
34+
35+
sealed class CachedToken
36+
{
37+
public string Token { get; set; }
38+
public DateTime ExpiresAtUtc { get; set; }
39+
}
40+
41+
string CacheKey => $"{_tokenUrl}|{_clientId}";
42+
2343
/// <summary>
2444
/// Initialize the OAuth2 Authenticator
2545
/// </summary>
@@ -63,10 +83,24 @@ namespace {{packageName}}.Client.Auth
6383
/// <returns>An authentication parameter.</returns>
6484
protected override async ValueTask<Parameter> GetAuthenticationParameter(string accessToken)
6585
{
66-
var token = string.IsNullOrEmpty(Token) ? await GetToken().ConfigureAwait(false) : Token;
86+
var token = await GetCachedOrFetchToken().ConfigureAwait(false);
6787
return new HeaderParameter(KnownHeaders.Authorization, token);
6888
}
6989

90+
/// <summary>
91+
/// Returns a cached, unexpired token when one is available; otherwise fetches a new one.
92+
/// </summary>
93+
/// <returns>An authentication token.</returns>
94+
async Task<string> GetCachedOrFetchToken()
95+
{
96+
if (_tokenCache.TryGetValue(CacheKey, out var cached) && cached.ExpiresAtUtc > DateTime.UtcNow)
97+
{
98+
return cached.Token;
99+
}
100+
101+
return await GetToken().ConfigureAwait(false);
102+
}
103+
70104
/// <summary>
71105
/// Gets the token from the OAuth2 server.
72106
/// </summary>
@@ -81,17 +115,34 @@ namespace {{packageName}}.Client.Auth
81115
.AddHeader("Authorization", $"Basic {credentials}")
82116
.AddParameter("grant_type", _grantType, ParameterType.GetOrPost);
83117
var response = await client.PostAsync<TokenResponse>(request).ConfigureAwait(false);
84-
118+
85119
// RFC6749 - token_type is case insensitive.
86120
// RFC6750 - In Authorization header Bearer should be capitalized.
87121
// Fix the capitalization irrespective of token_type casing.
122+
string token;
88123
switch (response.TokenType?.ToLower())
89124
{
90125
case "bearer":
91-
return $"Bearer {response.AccessToken}";
126+
token = $"Bearer {response.AccessToken}";
127+
break;
92128
default:
93-
return $"{response.TokenType} {response.AccessToken}";
129+
token = $"{response.TokenType} {response.AccessToken}";
130+
break;
131+
}
132+
133+
// Only cache when the server tells us how long the token is valid. Caching without a known
134+
// expiry would risk serving a stale token indefinitely; when expires_in is absent we fall back
135+
// to fetching per request (always valid, just not cached).
136+
if (response.ExpiresIn > TokenExpiryBufferSeconds)
137+
{
138+
_tokenCache[CacheKey] = new CachedToken
139+
{
140+
Token = token,
141+
ExpiresAtUtc = DateTime.UtcNow.AddSeconds(response.ExpiresIn - TokenExpiryBufferSeconds)
142+
};
94143
}
144+
145+
return token;
95146
}
96147
}
97148
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{{>partial_header}}
2+
3+
using Newtonsoft.Json;
4+
5+
namespace {{packageName}}.Client.Auth
6+
{
7+
class TokenResponse
8+
{
9+
[JsonProperty("token_type")]
10+
public string TokenType { get; set; }
11+
[JsonProperty("access_token")]
12+
public string AccessToken { get; set; }
13+
[JsonProperty("expires_in")]
14+
public int ExpiresIn { get; set; }
15+
}
16+
}
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
/*
2+
* Bandwidth
3+
*
4+
* Bandwidth's Communication APIs
5+
*
6+
* The version of the OpenAPI document: 1.0.0
7+
* Contact: letstalk@bandwidth.com
8+
* Generated by: https://github.com/openapitools/openapi-generator.git
9+
*/
10+
11+
12+
using System;
13+
using System.Net;
14+
using System.Net.Sockets;
15+
using System.Text;
16+
using System.Threading;
17+
using System.Threading.Tasks;
18+
using Xunit;
19+
using Newtonsoft.Json;
20+
using Bandwidth.Standard.Client;
21+
using Bandwidth.Standard.Client.Auth;
22+
23+
namespace Bandwidth.Standard.Test.Unit.Client
24+
{
25+
/// <summary>
26+
/// Class for testing OAuthAuthenticator token handling and caching.
27+
/// </summary>
28+
public class OAuthAuthenticatorTests : IDisposable
29+
{
30+
private readonly HttpListener _listener;
31+
private readonly string _tokenUrl;
32+
private readonly string _clientId;
33+
private const string ClientSecret = "test-secret";
34+
private const string AccessToken = "test-access-token";
35+
36+
// Each call to the fake token endpoint increments this so tests can assert how
37+
// many times a token was actually fetched from the server.
38+
private int _tokenRequestCount;
39+
private string _lastAuthorizationHeader;
40+
41+
// Controls whether the fake endpoint returns expires_in; toggled per test.
42+
private int _expiresInSeconds = 3600;
43+
private bool _includeExpiresIn = true;
44+
45+
public OAuthAuthenticatorTests()
46+
{
47+
// Unique client id per test keeps the authenticator's process-wide static
48+
// token cache isolated between tests (cache key is tokenUrl + clientId).
49+
_clientId = "test-client-" + Guid.NewGuid().ToString("N");
50+
51+
int port = GetFreeTcpPort();
52+
_tokenUrl = $"http://localhost:{port}/oauth2/token";
53+
54+
_listener = new HttpListener();
55+
_listener.Prefixes.Add($"http://localhost:{port}/");
56+
_listener.Start();
57+
_ = Task.Run(ServeTokenRequestsAsync);
58+
}
59+
60+
public void Dispose()
61+
{
62+
if (_listener.IsListening)
63+
_listener.Stop();
64+
_listener.Close();
65+
}
66+
67+
/// <summary>
68+
/// A valid token (with expires_in) should be fetched once and reused on subsequent calls.
69+
/// </summary>
70+
[Fact]
71+
public async Task GetAuthenticationParameter_CachesToken_FetchesOnlyOnce()
72+
{
73+
var authenticator = CreateAuthenticator();
74+
75+
string first = await authenticator.GetAuthHeaderAsync();
76+
string second = await authenticator.GetAuthHeaderAsync();
77+
78+
Assert.Equal($"Bearer {AccessToken}", first);
79+
Assert.Equal(first, second);
80+
Assert.Equal(1, _tokenRequestCount);
81+
}
82+
83+
/// <summary>
84+
/// The token request should authenticate with the client credentials via HTTP Basic auth.
85+
/// </summary>
86+
[Fact]
87+
public async Task GetAuthenticationParameter_SendsClientCredentialsAsBasicAuth()
88+
{
89+
var authenticator = CreateAuthenticator();
90+
91+
await authenticator.GetAuthHeaderAsync();
92+
93+
string expected = "Basic " + Convert.ToBase64String(
94+
Encoding.UTF8.GetBytes($"{_clientId}:{ClientSecret}"));
95+
Assert.Equal(expected, _lastAuthorizationHeader);
96+
}
97+
98+
/// <summary>
99+
/// When the server omits expires_in, the token must not be cached (falls back to fetching per call).
100+
/// </summary>
101+
[Fact]
102+
public async Task GetAuthenticationParameter_WithoutExpiresIn_FetchesEachTime()
103+
{
104+
_includeExpiresIn = false;
105+
var authenticator = CreateAuthenticator();
106+
107+
await authenticator.GetAuthHeaderAsync();
108+
await authenticator.GetAuthHeaderAsync();
109+
110+
Assert.Equal(2, _tokenRequestCount);
111+
}
112+
113+
private TestableOAuthAuthenticator CreateAuthenticator()
114+
{
115+
return new TestableOAuthAuthenticator(
116+
_tokenUrl,
117+
_clientId,
118+
ClientSecret,
119+
OAuthFlow.APPLICATION,
120+
new JsonSerializerSettings(),
121+
new Configuration());
122+
}
123+
124+
private async Task ServeTokenRequestsAsync()
125+
{
126+
while (_listener.IsListening)
127+
{
128+
HttpListenerContext context;
129+
try
130+
{
131+
context = await _listener.GetContextAsync();
132+
}
133+
catch
134+
{
135+
// Listener stopped/disposed during teardown.
136+
break;
137+
}
138+
139+
Interlocked.Increment(ref _tokenRequestCount);
140+
_lastAuthorizationHeader = context.Request.Headers["Authorization"];
141+
142+
string body = _includeExpiresIn
143+
? $"{{\"token_type\":\"Bearer\",\"access_token\":\"{AccessToken}\",\"expires_in\":{_expiresInSeconds}}}"
144+
: $"{{\"token_type\":\"Bearer\",\"access_token\":\"{AccessToken}\"}}";
145+
146+
byte[] bytes = Encoding.UTF8.GetBytes(body);
147+
context.Response.ContentType = "application/json";
148+
context.Response.ContentLength64 = bytes.Length;
149+
await context.Response.OutputStream.WriteAsync(bytes, 0, bytes.Length);
150+
context.Response.Close();
151+
}
152+
}
153+
154+
private static int GetFreeTcpPort()
155+
{
156+
var listener = new TcpListener(IPAddress.Loopback, 0);
157+
listener.Start();
158+
int port = ((IPEndPoint)listener.LocalEndpoint).Port;
159+
listener.Stop();
160+
return port;
161+
}
162+
163+
/// <summary>
164+
/// Exposes the protected GetAuthenticationParameter as the resolved Authorization header value.
165+
/// </summary>
166+
private sealed class TestableOAuthAuthenticator : OAuthAuthenticator
167+
{
168+
public TestableOAuthAuthenticator(
169+
string tokenUrl,
170+
string clientId,
171+
string clientSecret,
172+
OAuthFlow? flow,
173+
JsonSerializerSettings serializerSettings,
174+
IReadableConfiguration configuration)
175+
: base(tokenUrl, clientId, clientSecret, flow, serializerSettings, configuration)
176+
{
177+
}
178+
179+
public async Task<string> GetAuthHeaderAsync()
180+
{
181+
var parameter = await GetAuthenticationParameter(string.Empty);
182+
return parameter.Value?.ToString();
183+
}
184+
}
185+
}
186+
}

src/Bandwidth.Standard/Client/Auth/OAuthAuthenticator.cs

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111

1212
using System;
13+
using System.Collections.Concurrent;
1314
using System.Threading.Tasks;
1415
using Newtonsoft.Json;
1516
using RestSharp;
@@ -29,6 +30,25 @@ public class OAuthAuthenticator : AuthenticatorBase
2930
readonly JsonSerializerSettings _serializerSettings;
3031
readonly IReadableConfiguration _configuration;
3132

33+
/// <summary>
34+
/// Refresh slightly before the server-stated expiry so a token is never used in-flight as it expires.
35+
/// </summary>
36+
const int TokenExpiryBufferSeconds = 60;
37+
38+
/// <summary>
39+
/// Process-wide token cache, keyed by token URL + client id. A new OAuthAuthenticator is created per
40+
/// request by the ApiClient, so the cache must be static for tokens to be reused across requests.
41+
/// </summary>
42+
static readonly ConcurrentDictionary<string, CachedToken> _tokenCache = new ConcurrentDictionary<string, CachedToken>();
43+
44+
sealed class CachedToken
45+
{
46+
public string Token { get; set; }
47+
public DateTime ExpiresAtUtc { get; set; }
48+
}
49+
50+
string CacheKey => $"{_tokenUrl}|{_clientId}";
51+
3252
/// <summary>
3353
/// Initialize the OAuth2 Authenticator
3454
/// </summary>
@@ -72,10 +92,24 @@ public OAuthAuthenticator(
7292
/// <returns>An authentication parameter.</returns>
7393
protected override async ValueTask<Parameter> GetAuthenticationParameter(string accessToken)
7494
{
75-
var token = string.IsNullOrEmpty(Token) ? await GetToken().ConfigureAwait(false) : Token;
95+
var token = await GetCachedOrFetchToken().ConfigureAwait(false);
7696
return new HeaderParameter(KnownHeaders.Authorization, token);
7797
}
7898

99+
/// <summary>
100+
/// Returns a cached, unexpired token when one is available; otherwise fetches a new one.
101+
/// </summary>
102+
/// <returns>An authentication token.</returns>
103+
async Task<string> GetCachedOrFetchToken()
104+
{
105+
if (_tokenCache.TryGetValue(CacheKey, out var cached) && cached.ExpiresAtUtc > DateTime.UtcNow)
106+
{
107+
return cached.Token;
108+
}
109+
110+
return await GetToken().ConfigureAwait(false);
111+
}
112+
79113
/// <summary>
80114
/// Gets the token from the OAuth2 server.
81115
/// </summary>
@@ -90,17 +124,34 @@ async Task<string> GetToken()
90124
.AddHeader("Authorization", $"Basic {credentials}")
91125
.AddParameter("grant_type", _grantType, ParameterType.GetOrPost);
92126
var response = await client.PostAsync<TokenResponse>(request).ConfigureAwait(false);
93-
127+
94128
// RFC6749 - token_type is case insensitive.
95129
// RFC6750 - In Authorization header Bearer should be capitalized.
96130
// Fix the capitalization irrespective of token_type casing.
131+
string token;
97132
switch (response.TokenType?.ToLower())
98133
{
99134
case "bearer":
100-
return $"Bearer {response.AccessToken}";
135+
token = $"Bearer {response.AccessToken}";
136+
break;
101137
default:
102-
return $"{response.TokenType} {response.AccessToken}";
138+
token = $"{response.TokenType} {response.AccessToken}";
139+
break;
140+
}
141+
142+
// Only cache when the server tells us how long the token is valid. Caching without a known
143+
// expiry would risk serving a stale token indefinitely; when expires_in is absent we fall back
144+
// to fetching per request (always valid, just not cached).
145+
if (response.ExpiresIn > TokenExpiryBufferSeconds)
146+
{
147+
_tokenCache[CacheKey] = new CachedToken
148+
{
149+
Token = token,
150+
ExpiresAtUtc = DateTime.UtcNow.AddSeconds(response.ExpiresIn - TokenExpiryBufferSeconds)
151+
};
103152
}
153+
154+
return token;
104155
}
105156
}
106157
}

0 commit comments

Comments
 (0)