Skip to content

Commit 04db2ac

Browse files
bart-vmwareTimHess
andcommitted
Add expiration to JWT/OpenID keys caching
Refactor cache invalidation for JWT keys and tests --------- Co-authored-by: Tim Hess <tim.hess@broadcom.com>
1 parent 463dcbc commit 04db2ac

12 files changed

Lines changed: 1207 additions & 200 deletions

src/Security/src/Authentication.JwtBearer/JwtBearerAuthenticationBuilderExtensions.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using Microsoft.AspNetCore.Authentication;
66
using Microsoft.AspNetCore.Authentication.JwtBearer;
77
using Microsoft.Extensions.DependencyInjection;
8+
using Microsoft.Extensions.DependencyInjection.Extensions;
89
using Microsoft.Extensions.Options;
910

1011
namespace Steeltoe.Security.Authentication.JwtBearer;
@@ -24,6 +25,8 @@ public static AuthenticationBuilder ConfigureJwtBearerForCloudFoundry(this Authe
2425
{
2526
ArgumentNullException.ThrowIfNull(builder);
2627

28+
builder.Services.TryAddSingleton(TimeProvider.System);
29+
builder.Services.TryAddSingleton<TokenKeyResolver>();
2730
builder.Services.AddSingleton<IPostConfigureOptions<JwtBearerOptions>, PostConfigureJwtBearerOptions>();
2831
return builder;
2932
}

src/Security/src/Authentication.JwtBearer/PostConfigureJwtBearerOptions.cs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using Microsoft.AspNetCore.Authentication.JwtBearer;
66
using Microsoft.Extensions.Configuration;
77
using Microsoft.Extensions.Options;
8+
using Microsoft.IdentityModel.Tokens;
89
using Steeltoe.Common;
910

1011
namespace Steeltoe.Security.Authentication.JwtBearer;
@@ -13,12 +14,15 @@ internal sealed class PostConfigureJwtBearerOptions : IPostConfigureOptions<JwtB
1314
{
1415
private const string BearerConfigurationKeyPrefix = "Authentication:Schemes:Bearer";
1516
private readonly IConfiguration _configuration;
17+
private readonly TokenKeyResolver _tokenKeyResolver;
1618

17-
public PostConfigureJwtBearerOptions(IConfiguration configuration)
19+
public PostConfigureJwtBearerOptions(IConfiguration configuration, TokenKeyResolver tokenKeyResolver)
1820
{
1921
ArgumentNullException.ThrowIfNull(configuration);
22+
ArgumentNullException.ThrowIfNull(tokenKeyResolver);
2023

2124
_configuration = configuration;
25+
_tokenKeyResolver = tokenKeyResolver;
2226
}
2327

2428
public void PostConfigure(string? name, JwtBearerOptions options)
@@ -56,7 +60,10 @@ public void PostConfigure(string? name, JwtBearerOptions options)
5660
options.TokenValidationParameters.ValidIssuer = $"{options.Authority}/oauth/token";
5761
}
5862

59-
var keyResolver = new TokenKeyResolver(options.Authority, options.Backchannel);
60-
options.TokenValidationParameters.IssuerSigningKeyResolver = (_, _, keyId, _) => keyResolver.ResolveSigningKey(keyId);
63+
options.TokenValidationParameters.IssuerSigningKeyResolver = (_, _, keyId, _) =>
64+
{
65+
JsonWebKey? key = _tokenKeyResolver.ResolveSigningKey(options.Authority, keyId, options.Backchannel);
66+
return key != null ? [key] : [];
67+
};
6168
}
6269
}

src/Security/src/Authentication.JwtBearer/Steeltoe.Security.Authentication.JwtBearer.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
<ItemGroup>
1212
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="$(MatchTargetFrameworkVersion)" />
13+
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="$(FoundationalVersion)" />
1314
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="$(MicrosoftIdentityModelVersion)" />
1415
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="$(MicrosoftIdentityModelVersion)" />
1516
</ItemGroup>

src/Security/src/Authentication.JwtBearer/TokenKeyResolver.cs

Lines changed: 134 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2,76 +2,181 @@
22
// The .NET Foundation licenses this file to you under the Apache 2.0 License.
33
// See the LICENSE file in the project root for more information.
44

5-
using System.Collections.Concurrent;
65
using System.Net.Http.Headers;
6+
using Microsoft.Extensions.Caching.Memory;
7+
using Microsoft.Extensions.Internal;
8+
using Microsoft.Extensions.Logging;
79
using Microsoft.IdentityModel.Tokens;
10+
using Steeltoe.Common.Extensions;
811

912
namespace Steeltoe.Security.Authentication.JwtBearer;
1013

11-
internal sealed class TokenKeyResolver
14+
internal sealed partial class TokenKeyResolver : IDisposable
1215
{
1316
private static readonly MediaTypeWithQualityHeaderValue AcceptHeader = new("application/json");
14-
private readonly HttpClient _httpClient;
15-
private readonly Uri _authorityUri;
17+
private static readonly TimeSpan CacheTimeToLiveForKeyFound = TimeSpan.FromHours(12);
18+
private static readonly TimeSpan CacheMinTimeToLiveForKeyNotFound = TimeSpan.FromSeconds(30);
19+
private static readonly TimeSpan CacheMaxTimeToLiveForKeyNotFound = TimeSpan.FromSeconds(60);
20+
private readonly MemoryCache _cache;
21+
private readonly ILogger<TokenKeyResolver> _logger;
1622

17-
internal static ConcurrentDictionary<string, SecurityKey> ResolvedSecurityKeysById { get; } = new();
23+
public TokenKeyResolver(TimeProvider timeProvider, ILoggerFactory loggerFactory)
24+
{
25+
ArgumentNullException.ThrowIfNull(timeProvider);
26+
ArgumentNullException.ThrowIfNull(loggerFactory);
27+
28+
_cache = new MemoryCache(new MemoryCacheOptions
29+
{
30+
Clock = new TimeProviderSystemClock(timeProvider)
31+
}, loggerFactory);
1832

19-
public TokenKeyResolver(string authority, HttpClient httpClient)
33+
_logger = loggerFactory.CreateLogger<TokenKeyResolver>();
34+
}
35+
36+
internal JsonWebKey? ResolveSigningKey(string authority, string keyId, HttpClient httpClient)
2037
{
2138
ArgumentException.ThrowIfNullOrWhiteSpace(authority);
39+
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
2240
ArgumentNullException.ThrowIfNull(httpClient);
2341

42+
Uri tokenKeysUri = GetTokenKeysUri(authority);
43+
return CachingResolveSigningKey(tokenKeysUri, keyId, httpClient);
44+
}
45+
46+
private static Uri GetTokenKeysUri(string authority)
47+
{
2448
if (!authority.EndsWith('/'))
2549
{
2650
authority += '/';
2751
}
2852

29-
_authorityUri = new Uri($"{authority}token_keys");
30-
_httpClient = httpClient;
53+
var authorityUri = new Uri(authority);
54+
return new Uri(authorityUri, "token_keys");
3155
}
3256

33-
internal SecurityKey[] ResolveSigningKey(string keyId)
57+
private JsonWebKey? CachingResolveSigningKey(Uri tokenKeysUri, string keyId, HttpClient httpClient)
3458
{
35-
if (ResolvedSecurityKeysById.TryGetValue(keyId, out SecurityKey? resolved))
59+
string cacheKey = GetCacheKey(tokenKeysUri, keyId);
60+
61+
if (!_cache.TryGetValue<JsonWebKey?>(cacheKey, out JsonWebKey? matchingWebKey))
3662
{
37-
return [resolved];
63+
JsonWebKeySet? webKeySet = FetchKeySet(tokenKeysUri, httpClient);
64+
65+
foreach (JsonWebKey nextWebKey in webKeySet?.Keys ?? [])
66+
{
67+
string nextCacheKey = GetCacheKey(tokenKeysUri, nextWebKey.Kid);
68+
_cache.Set(nextCacheKey, nextWebKey, CacheTimeToLiveForKeyFound);
69+
70+
if (nextWebKey.Kid == keyId)
71+
{
72+
matchingWebKey = nextWebKey;
73+
}
74+
}
75+
76+
if (matchingWebKey == null)
77+
{
78+
TimeSpan timeToLive = GetTimeToLiveForNotFound();
79+
_cache.Set<JsonWebKey?>(cacheKey, null, timeToLive);
80+
81+
if (webKeySet == null)
82+
{
83+
LogDisableFetchAfterServerError(keyId, (int)timeToLive.TotalSeconds);
84+
}
85+
else
86+
{
87+
LogDisableFetchAfterKeyNotFound(keyId, (int)timeToLive.TotalSeconds);
88+
}
89+
}
3890
}
3991

92+
return matchingWebKey;
93+
}
94+
95+
private static string GetCacheKey(Uri tokenKeysUri, string keyId)
96+
{
97+
return $"{tokenKeysUri}:{keyId}";
98+
}
99+
100+
private static TimeSpan GetTimeToLiveForNotFound()
101+
{
102+
double jitterSeconds = Random.Shared.NextDouble() * (CacheMaxTimeToLiveForKeyNotFound - CacheMinTimeToLiveForKeyNotFound).TotalSeconds;
103+
return CacheMinTimeToLiveForKeyNotFound + TimeSpan.FromSeconds(jitterSeconds);
104+
}
105+
106+
private JsonWebKeySet? FetchKeySet(Uri tokenKeysUri, HttpClient httpClient)
107+
{
40108
#pragma warning disable S4462 // Calls to "async" methods should not be blocking
41109
// Justification: can't be async all the way until updates are complete in Microsoft libraries
42110
// https://github.com/AzureAD/azure-activedirectory-identitymodel-extensions-for-dotnet/issues/468
43-
JsonWebKeySet? keySet = FetchKeySetAsync(CancellationToken.None).GetAwaiter().GetResult();
111+
return FetchKeySetAsync(tokenKeysUri, httpClient, CancellationToken.None).GetAwaiter().GetResult();
44112
#pragma warning restore S4462 // Calls to "async" methods should not be blocking
113+
}
114+
115+
private async Task<JsonWebKeySet?> FetchKeySetAsync(Uri tokenKeysUri, HttpClient httpClient, CancellationToken cancellationToken)
116+
{
117+
using var requestMessage = new HttpRequestMessage(HttpMethod.Get, tokenKeysUri);
118+
requestMessage.Headers.Accept.Add(AcceptHeader);
45119

46-
if (keySet != null)
120+
HttpResponseMessage response;
121+
122+
try
47123
{
48-
foreach (JsonWebKey key in keySet.Keys)
49-
{
50-
ResolvedSecurityKeysById[key.Kid] = key;
51-
}
124+
response = await httpClient.SendAsync(requestMessage, cancellationToken);
125+
}
126+
catch (Exception exception) when (exception is HttpRequestException || exception.IsHttpClientTimeout())
127+
{
128+
LogTokenKeysEndpointUnreachable(exception, tokenKeysUri);
129+
return null;
52130
}
53131

54-
if (ResolvedSecurityKeysById.TryGetValue(keyId, out resolved))
132+
if (!response.IsSuccessStatusCode)
55133
{
56-
return [resolved];
134+
LogFetchTokenKeysStatusFailed(tokenKeysUri, (int)response.StatusCode);
135+
return null;
57136
}
58137

59-
return [];
138+
try
139+
{
140+
string result = await response.Content.ReadAsStringAsync(cancellationToken);
141+
return JsonWebKeySet.Create(result);
142+
}
143+
catch (ArgumentException exception)
144+
{
145+
LogFetchTokenKeysParseFailed(exception, tokenKeysUri);
146+
return null;
147+
}
60148
}
61149

62-
internal async Task<JsonWebKeySet?> FetchKeySetAsync(CancellationToken cancellationToken)
150+
public void Dispose()
63151
{
64-
using var requestMessage = new HttpRequestMessage(HttpMethod.Get, _authorityUri);
65-
requestMessage.Headers.Accept.Add(AcceptHeader);
152+
_cache.Dispose();
153+
}
66154

67-
HttpResponseMessage response = await _httpClient.SendAsync(requestMessage, cancellationToken);
155+
[LoggerMessage(LogLevel.Warning, "Fetch keys from '{TokenKeysUri}' failed.")]
156+
private partial void LogTokenKeysEndpointUnreachable(Exception exception, MaskedUri tokenKeysUri);
68157

69-
if (!response.IsSuccessStatusCode)
158+
[LoggerMessage(LogLevel.Warning, "Fetch keys from '{TokenKeysUri}' failed with HTTP status {StatusCode}.")]
159+
private partial void LogFetchTokenKeysStatusFailed(MaskedUri tokenKeysUri, int statusCode);
160+
161+
[LoggerMessage(LogLevel.Warning, "Fetch keys from '{TokenKeysUri}' failed because the returned JSON is invalid.")]
162+
private partial void LogFetchTokenKeysParseFailed(Exception exception, MaskedUri tokenKeysUri);
163+
164+
[LoggerMessage(LogLevel.Information, "Disabled fetch for key '{KeyId}' for {RetryAfterSeconds}s because the HTTP request failed.")]
165+
private partial void LogDisableFetchAfterServerError(string keyId, int retryAfterSeconds);
166+
167+
[LoggerMessage(LogLevel.Information, "Disabled fetch for key '{KeyId}' for {RetryAfterSeconds}s because the key was not found in the HTTP response.")]
168+
private partial void LogDisableFetchAfterKeyNotFound(string keyId, int retryAfterSeconds);
169+
170+
private sealed class TimeProviderSystemClock : ISystemClock
171+
{
172+
private readonly TimeProvider _timeProvider;
173+
174+
public DateTimeOffset UtcNow => _timeProvider.GetUtcNow();
175+
176+
public TimeProviderSystemClock(TimeProvider timeProvider)
70177
{
71-
return null;
178+
ArgumentNullException.ThrowIfNull(timeProvider);
179+
_timeProvider = timeProvider;
72180
}
73-
74-
string result = await response.Content.ReadAsStringAsync(cancellationToken);
75-
return JsonWebKeySet.Create(result);
76181
}
77182
}

src/Security/src/Authentication.OpenIdConnect/OpenIdConnectAuthenticationBuilderExtensions.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using Microsoft.AspNetCore.Authentication;
66
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
77
using Microsoft.Extensions.DependencyInjection;
8+
using Microsoft.Extensions.DependencyInjection.Extensions;
89
using Microsoft.Extensions.Options;
910

1011
namespace Steeltoe.Security.Authentication.OpenIdConnect;
@@ -24,6 +25,8 @@ public static AuthenticationBuilder ConfigureOpenIdConnectForCloudFoundry(this A
2425
{
2526
ArgumentNullException.ThrowIfNull(builder);
2627

28+
builder.Services.TryAddSingleton(TimeProvider.System);
29+
builder.Services.TryAddSingleton<TokenKeyResolver>();
2730
builder.Services.AddSingleton<IPostConfigureOptions<OpenIdConnectOptions>, PostConfigureOpenIdConnectOptions>();
2831
return builder;
2932
}

src/Security/src/Authentication.OpenIdConnect/PostConfigureOpenIdConnectOptions.cs

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,28 @@
77
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
88
using Microsoft.Extensions.Options;
99
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
10+
using Microsoft.IdentityModel.Tokens;
1011

1112
namespace Steeltoe.Security.Authentication.OpenIdConnect;
1213

1314
internal sealed class PostConfigureOpenIdConnectOptions : IPostConfigureOptions<OpenIdConnectOptions>
1415
{
15-
// The ClaimsIdentity is built off the id_token, but scopes are returned in the access_token.
16-
// Identify scopes not already present as claims and add them to the ClaimsIdentity
16+
private readonly TokenKeyResolver _tokenKeyResolver;
17+
18+
public PostConfigureOpenIdConnectOptions(TokenKeyResolver tokenKeyResolver)
19+
{
20+
ArgumentNullException.ThrowIfNull(tokenKeyResolver);
21+
22+
_tokenKeyResolver = tokenKeyResolver;
23+
}
24+
1725
private static Task MapScopesToClaimsAsync(TokenValidatedContext context)
1826
{
27+
ArgumentNullException.ThrowIfNull(context);
28+
29+
// The ClaimsIdentity is built off the id_token, but scopes are returned in the access_token.
30+
// Identify scopes not already present as claims and add them to the ClaimsIdentity.
31+
1932
if (context.Principal?.Identity is not ClaimsIdentity claimsIdentity)
2033
{
2134
return Task.CompletedTask;
@@ -54,7 +67,10 @@ public void PostConfigure(string? name, OpenIdConnectOptions options)
5467

5568
options.TokenValidationParameters.ValidIssuer = $"{options.Authority}/oauth/token";
5669

57-
var keyResolver = new TokenKeyResolver(options.Authority, options.Backchannel);
58-
options.TokenValidationParameters.IssuerSigningKeyResolver = (_, _, keyId, _) => keyResolver.ResolveSigningKey(keyId);
70+
options.TokenValidationParameters.IssuerSigningKeyResolver = (_, _, keyId, _) =>
71+
{
72+
JsonWebKey? key = _tokenKeyResolver.ResolveSigningKey(options.Authority, keyId, options.Backchannel);
73+
return key != null ? [key] : [];
74+
};
5975
}
6076
}

src/Security/src/Authentication.OpenIdConnect/Steeltoe.Security.Authentication.OpenIdConnect.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
<ItemGroup>
1212
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="$(MatchTargetFrameworkVersion)" />
13+
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="$(FoundationalVersion)" />
1314
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="$(MicrosoftIdentityModelVersion)" />
1415
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="$(MicrosoftIdentityModelVersion)" />
1516
</ItemGroup>

0 commit comments

Comments
 (0)