Skip to content
Closed
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
6 changes: 3 additions & 3 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,14 @@

<PropertyGroup Condition=" '$(TargetFramework)' == 'net8.0'">
<FrameworkVersion>8.0.1</FrameworkVersion>
<ExtensionsVersion>8.0.0</ExtensionsVersion>
<ExtensionsVersion>9.0.3</ExtensionsVersion>
<WilsonVersion>[8.0.1,9.0.0)</WilsonVersion>
<IdentityServerVersion>7.0.8</IdentityServerVersion>
</PropertyGroup>

<PropertyGroup Condition=" '$(TargetFramework)' == 'net9.0'">
<FrameworkVersion>9.0.0</FrameworkVersion>
<ExtensionsVersion>9.0.0</ExtensionsVersion>
<ExtensionsVersion>9.0.3</ExtensionsVersion>
<WilsonVersion>[8.0.1,9.0.0)</WilsonVersion>
<IdentityServerVersion>7.0.8</IdentityServerVersion>
</PropertyGroup>
Expand All @@ -40,7 +40,7 @@
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="$(FrameworkVersion)" />
<PackageVersion Include="Microsoft.AspNetCore.TestHost" Version="$(FrameworkVersion)" />
<PackageVersion Include="Microsoft.AspNetCore.WebUtilities" Version="2.2.0" />
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="$(ExtensionsVersion)" />
<PackageVersion Include="Microsoft.Extensions.Caching.Hybrid " Version="9.3.0" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="$(ExtensionsVersion)" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="$(ExtensionsVersion)" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="$(ExtensionsVersion)" />
Expand Down
2 changes: 0 additions & 2 deletions access-token-management/samples/WorkerDI/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,6 @@ public static IHostBuilder CreateHostBuilder(string[] args)

.ConfigureServices((services) =>
{
services.AddDistributedMemoryCache();

services.AddClientCredentialsTokenManagement();
services.AddSingleton(new DiscoveryCache("https://demo.duendesoftware.com"));
services.AddSingleton<IConfigureOptions<ClientCredentialsClient>, ClientCredentialsClientConfigureOptions>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Duende.IdentityModel" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Caching.Hybrid" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,7 @@ namespace Duende.AccessTokenManagement;
/// </summary>
public class ClientCredentialsTokenManagementService(
IClientCredentialsTokenEndpointService clientCredentialsTokenEndpointService,
IClientCredentialsTokenCache tokenCache,
ILogger<ClientCredentialsTokenManagementService> logger
IClientCredentialsTokenCache tokenCache
) : IClientCredentialsTokenManagementService
{

Expand All @@ -24,27 +23,6 @@ public async Task<ClientCredentialsToken> GetAccessTokenAsync(
{
parameters ??= new TokenRequestParameters();

if (parameters.ForceRenewal == false)
{
try
{
var item = await tokenCache.GetAsync(
clientName: clientName,
requestParameters: parameters,
cancellationToken: cancellationToken).ConfigureAwait(false);
if (item != null)
{
return item;
}
}
catch (Exception e)
{
logger.LogError(e,
"Error trying to obtain token from cache for client {clientName}. Error = {error}. Will obtain new token.",
clientName, e.Message);
}
}

return await tokenCache.GetOrCreateAsync(
clientName: clientName,
requestParameters: parameters,
Expand All @@ -58,12 +36,12 @@ private async Task<ClientCredentialsToken> InvokeGetAccessToken(string clientNam
}

/// <inheritdoc/>
public Task DeleteAccessTokenAsync(
public async Task DeleteAccessTokenAsync(
string clientName,
TokenRequestParameters? parameters = null,
CancellationToken cancellationToken = default)
{
parameters ??= new TokenRequestParameters();
return tokenCache.DeleteAsync(clientName, parameters, cancellationToken);
await tokenCache.DeleteAsync(clientName, parameters, cancellationToken);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.

using Duende.AccessTokenManagement;
using Microsoft.Extensions.Caching.Hybrid;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;

Expand Down Expand Up @@ -48,6 +49,19 @@ public static ClientCredentialsTokenManagementBuilder AddClientCredentialsTokenM

services.AddHttpClient(ClientCredentialsTokenManagementDefaults.BackChannelHttpClientName);

// We can add the hybrid cache, because it does a 'tryadd' everywhere
services.AddHybridCache();

// Add indirection from keyed service to non-keyed service
services.TryAddKeyedSingleton<HybridCache>(
serviceKey: ServiceProviderKeys.DistributedClientCredentialsTokenCache,
implementationFactory: (sp, _) => sp.GetRequiredService<HybridCache>());

// Add indirection from keyed service to non-keyed service
services.TryAddKeyedSingleton<HybridCache>(
serviceKey: ServiceProviderKeys.DistributedDPoPNonceStore,
implementationFactory: (sp, _) => sp.GetRequiredService<HybridCache>());

return new ClientCredentialsTokenManagementBuilder(services);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,31 +1,33 @@
// Copyright (c) Duende Software. All rights reserved.
// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.

using System.Collections.Concurrent;
using System.Text.Json;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Caching.Hybrid;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

namespace Duende.AccessTokenManagement;

public static class ServiceProviderKeys
{
public const string DistributedClientCredentialsTokenCache = "DistributedClientCredentialsTokenCache";
public const string DistributedDPoPNonceStore = "DistributedDPoPNonceStore";
}

/// <summary>
/// Client access token cache using IDistributedCache
/// </summary>
public class DistributedClientCredentialsTokenCache(
IDistributedCache cache,
[FromKeyedServices(ServiceProviderKeys.DistributedClientCredentialsTokenCache)]HybridCache cache,
TimeProvider time,
ITokenRequestSynchronization synchronization,
IOptions<ClientCredentialsTokenManagementOptions> options,
ILogger<DistributedClientCredentialsTokenCache> logger
)
: IClientCredentialsTokenCache
{
private readonly IDistributedCache _cache = cache;
private readonly ITokenRequestSynchronization _synchronization = synchronization;
private readonly ILogger<DistributedClientCredentialsTokenCache> _logger = logger;
private readonly ClientCredentialsTokenManagementOptions _options = options.Value;


/// <inheritdoc/>
public async Task SetAsync(
string clientName,
Expand All @@ -34,21 +36,38 @@ public async Task SetAsync(
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(clientName);

var cacheExpiration = clientCredentialsToken.Expiration.AddSeconds(-_options.CacheLifetimeBuffer);
var data = JsonSerializer.Serialize(clientCredentialsToken);

var entryOptions = new DistributedCacheEntryOptions
try
{
AbsoluteExpiration = cacheExpiration
};
var entryOptions = GetHybridCacheEntryOptions(clientCredentialsToken);

_logger.LogTrace("Caching access token for client: {clientName}. Expiration: {expiration}", clientName, cacheExpiration);

var cacheKey = GenerateCacheKey(_options, clientName, requestParameters);
await _cache.SetStringAsync(cacheKey, data, entryOptions, token: cancellationToken).ConfigureAwait(false);
var cacheKey = GenerateCacheKey(_options, clientName, requestParameters);
logger.LogTrace("Caching access token for client: {clientName}. Expiration: {expiration}", clientName, entryOptions.Expiration);
await cache.SetAsync(cacheKey, clientCredentialsToken, entryOptions, cancellationToken: cancellationToken).ConfigureAwait(false);
}
catch (Exception e)
{
logger.LogError(e,
"Error trying to set token in cache for client {clientName}. Error = {error}",
clientName, e.Message);
}
}

private HybridCacheEntryOptions GetHybridCacheEntryOptions(ClientCredentialsToken clientCredentialsToken)
{
var absoluteCacheExpiration = clientCredentialsToken.Expiration.AddSeconds(-_options.CacheLifetimeBuffer);
var relativeCacheExpiration = absoluteCacheExpiration - time.GetUtcNow();
var entryOptions = new HybridCacheEntryOptions()
{
Expiration = relativeCacheExpiration
};
return entryOptions;
}

private class TokenErrorException(ClientCredentialsToken token) : Exception
{
public ClientCredentialsToken Token { get; } = token;
}
public async Task<ClientCredentialsToken> GetOrCreateAsync(
string clientName, TokenRequestParameters requestParameters,
Func<string, TokenRequestParameters, CancellationToken, Task<ClientCredentialsToken>> factory,
Expand All @@ -58,72 +77,65 @@ public async Task<ClientCredentialsToken> GetOrCreateAsync(

var cacheKey = GenerateCacheKey(_options, clientName, requestParameters);

return await _synchronization.SynchronizeAsync(cacheKey, async () =>
ClientCredentialsToken? token;
if (!requestParameters.ForceRenewal)
{
var token = await factory(clientName, requestParameters, cancellationToken).ConfigureAwait(false);
if (token.IsError)
{
_logger.LogError(
"Error requesting access token for client {clientName}. Error = {error}.",
clientName, token.Error);

return token;
}
// We don't need the token to be absolutely fresh, so we can get one from the cache.
token = await cache.GetOrDefaultAsync<ClientCredentialsToken>(
key: cacheKey,
cancellationToken: cancellationToken);

try
{
await SetAsync(clientName, token, requestParameters, cancellationToken).ConfigureAwait(false);
}
catch (Exception e)
if (token?.Expiration > DateTimeOffset.MinValue)
{
_logger.LogError(e,
"Error trying to set token in cache for client {clientName}. Error = {error}",
clientName, e.Message);
var absoluteCacheExpiration = token.Expiration.AddSeconds(-_options.CacheLifetimeBuffer);

if (absoluteCacheExpiration > time.GetUtcNow())
{
// It's possible that we have only read the token from L2 cache, not L1 cache.
// just to be sure, write the token also into L1 cache (which should be fast)
// https://github.com/dotnet/extensions/issues/5688#issuecomment-2692247434
var defaultWriteOptions = GetHybridCacheEntryOptions(token);
await cache.SetAsync(cacheKey, token, new HybridCacheEntryOptions()
{
Flags = HybridCacheEntryFlags.DisableDistributedCacheWrite,
Expiration = defaultWriteOptions.Expiration,
LocalCacheExpiration = defaultWriteOptions.LocalCacheExpiration
}, cancellationToken: cancellationToken);

return token;
}
}
}

return token;
}).ConfigureAwait(false);
}

/// <inheritdoc/>
public async Task<ClientCredentialsToken?> GetAsync(
string clientName,
TokenRequestParameters requestParameters,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(clientName);

var cacheKey = GenerateCacheKey(_options, clientName, requestParameters);
var entry = await _cache.GetStringAsync(cacheKey, token: cancellationToken).ConfigureAwait(false);

if (entry != null)
// Apparently, there's either no value in the cache, or we want a fresh one.
// Since we aren't using GetOrCreate, we'll have to protect against cache stampedes ourselves.
return await synchronization.SynchronizeAsync(cacheKey, async () =>
{
try
{
_logger.LogDebug("Cache hit for access token for client: {clientName}", clientName);
return JsonSerializer.Deserialize<ClientCredentialsToken>(entry);
}
catch (Exception ex)
token = await factory(clientName, requestParameters, cancellationToken).ConfigureAwait(false);

// Don't cache the token if there's an error.
if (!token.IsError)
{
_logger.LogCritical(ex, "Error parsing cached access token for client {clientName}", clientName);
return null;
await SetAsync(clientName, token, requestParameters, cancellationToken);
}
}

_logger.LogTrace("Cache miss for access token for client: {clientName}", clientName);
return null;
return token;
});


}


/// <inheritdoc/>
public Task DeleteAsync(
public ValueTask DeleteAsync(
string clientName,
TokenRequestParameters requestParameters,
CancellationToken cancellationToken = default)
{
if (clientName is null) throw new ArgumentNullException(nameof(clientName));

var cacheKey = GenerateCacheKey(_options, clientName, requestParameters);
return _cache.RemoveAsync(cacheKey, cancellationToken);
return cache.RemoveAsync(cacheKey, cancellationToken);
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
// Copyright (c) Duende Software. All rights reserved.
// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.

using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Caching.Hybrid;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.Tokens;

namespace Duende.AccessTokenManagement;

Expand All @@ -14,16 +16,21 @@ public class DistributedDPoPNonceStore : IDPoPNonceStore
const string CacheKeyPrefix = "DistributedDPoPNonceStore";
const char CacheKeySeparator = ':';

private readonly IDistributedCache _cache;
private readonly HybridCache _cache;
private readonly ILogger<DistributedDPoPNonceStore> _logger;

private static readonly HybridCacheEntryOptions NonceStoreCacheOptions = new HybridCacheEntryOptions()
{
LocalCacheExpiration = TimeSpan.FromHours(1)
};

/// <summary>
/// ctor
/// </summary>
/// <param name="cache"></param>
/// <param name="logger"></param>
public DistributedDPoPNonceStore(
IDistributedCache cache,
[FromKeyedServices(ServiceProviderKeys.DistributedDPoPNonceStore)]HybridCache cache,
ILogger<DistributedDPoPNonceStore> logger)
{
_cache = cache;
Expand All @@ -36,7 +43,7 @@ public DistributedDPoPNonceStore(
ArgumentNullException.ThrowIfNull(context);

var cacheKey = GenerateCacheKey(context);
var entry = await _cache.GetStringAsync(cacheKey, token: cancellationToken).ConfigureAwait(false);
var entry = await _cache.GetOrDefaultAsync<string>(cacheKey).ConfigureAwait(false);

if (entry != null)
{
Expand All @@ -53,18 +60,13 @@ public virtual async Task StoreNonceAsync(DPoPNonceContext context, string nonce
{
ArgumentNullException.ThrowIfNull(context);

var cacheExpiration = DateTimeOffset.UtcNow.AddHours(1);
var data = nonce;

var entryOptions = new DistributedCacheEntryOptions
{
AbsoluteExpiration = cacheExpiration
};

_logger.LogTrace("Caching DPoP nonce for URL: {url}, method: {method}. Expiration: {expiration}", context.Url, context.Method, cacheExpiration);
_logger.LogTrace("Caching DPoP nonce for URL: {url}, method: {method}. Expiration: {expiration}", context.Url, context.Method, NonceStoreCacheOptions.Expiration);

var cacheKey = GenerateCacheKey(context);
await _cache.SetStringAsync(cacheKey, data, entryOptions, token: cancellationToken).ConfigureAwait(false);
await _cache.SetAsync(cacheKey, data, NonceStoreCacheOptions, cancellationToken: cancellationToken).ConfigureAwait(false);
}


Expand Down
Loading