From cc0008141962570b46991a30df287960207962e3 Mon Sep 17 00:00:00 2001 From: Joe DeCock Date: Fri, 3 May 2024 15:34:14 -0500 Subject: [PATCH 1/2] Add Authority to ClientCredentials options If authority is set, we use it to retrieve the discovery document, and use that to configure the token endpoint. Because this is an async operation, we have a new abstraction for retrieval of the token endpoint --- ...reOpenIdConnectClientCredentialsOptions.cs | 3 +- .../StringExtensions.cs | 3 +- .../ClientCredentialsClient.cs | 12 ++++- .../ClientCredentialsTokenEndpointService.cs | 13 +++--- ...enManagementServiceCollectionExtensions.cs | 2 + .../Interfaces/ITokenEndpointRetriever.cs | 14 ++++++ .../StringExtensions.cs | 24 ++++++++++ .../TokenEndpointRetriever.cs | 45 +++++++++++++++++++ test/Tests/Framework/AppHost.cs | 1 + .../Framework/TestTokenEndpointRetreiver.cs | 12 +++++ 10 files changed, 118 insertions(+), 11 deletions(-) create mode 100644 src/Duende.AccessTokenManagement/Interfaces/ITokenEndpointRetriever.cs create mode 100644 src/Duende.AccessTokenManagement/StringExtensions.cs create mode 100644 src/Duende.AccessTokenManagement/TokenEndpointRetriever.cs create mode 100644 test/Tests/Framework/TestTokenEndpointRetreiver.cs diff --git a/src/Duende.AccessTokenManagement.OpenIdConnect/ConfigureOpenIdConnectClientCredentialsOptions.cs b/src/Duende.AccessTokenManagement.OpenIdConnect/ConfigureOpenIdConnectClientCredentialsOptions.cs index 019d94f..4427c11 100644 --- a/src/Duende.AccessTokenManagement.OpenIdConnect/ConfigureOpenIdConnectClientCredentialsOptions.cs +++ b/src/Duende.AccessTokenManagement.OpenIdConnect/ConfigureOpenIdConnectClientCredentialsOptions.cs @@ -49,7 +49,8 @@ public void Configure(string? name, ClientCredentialsClient options) } var oidc = _configurationService.GetOpenIdConnectConfigurationAsync(scheme).GetAwaiter().GetResult(); - + + options.Authority = oidc.Authority; options.TokenEndpoint = oidc.TokenEndpoint; options.ClientId = oidc.ClientId; options.ClientSecret = oidc.ClientSecret; diff --git a/src/Duende.AccessTokenManagement.OpenIdConnect/StringExtensions.cs b/src/Duende.AccessTokenManagement.OpenIdConnect/StringExtensions.cs index 0e5de42..b3cdd02 100755 --- a/src/Duende.AccessTokenManagement.OpenIdConnect/StringExtensions.cs +++ b/src/Duende.AccessTokenManagement.OpenIdConnect/StringExtensions.cs @@ -6,6 +6,8 @@ namespace Duende.AccessTokenManagement.OpenIdConnect; +// Note that this is duplicated in Duende.AccessTokenManagement, but we can't +// share the code because it is internal. internal static class StringExtensions { [DebuggerStepThrough] @@ -19,5 +21,4 @@ public static bool IsPresent([NotNullWhen(true)]this string? value) { return !string.IsNullOrWhiteSpace(value); } - } \ No newline at end of file diff --git a/src/Duende.AccessTokenManagement/ClientCredentialsClient.cs b/src/Duende.AccessTokenManagement/ClientCredentialsClient.cs index 3ec11a5..64bd4c4 100644 --- a/src/Duende.AccessTokenManagement/ClientCredentialsClient.cs +++ b/src/Duende.AccessTokenManagement/ClientCredentialsClient.cs @@ -1,8 +1,12 @@ // Copyright (c) Brock Allen & Dominick Baier. All rights reserved. // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; using System.Net.Http; using IdentityModel.Client; +using Microsoft.Extensions.Options; namespace Duende.AccessTokenManagement; @@ -11,6 +15,12 @@ namespace Duende.AccessTokenManagement; /// public class ClientCredentialsClient { + /// + /// The address of the OAuth authority. If this is set, the TokenEndpoint + /// will be set using discovery. + /// + public string? Authority { get; set; } + /// /// The address of the token endpoint /// @@ -60,4 +70,4 @@ public class ClientCredentialsClient /// The string representation of the JSON web key to use for DPoP. /// public string? DPoPJsonWebKey { get; set; } -} \ No newline at end of file +} diff --git a/src/Duende.AccessTokenManagement/ClientCredentialsTokenEndpointService.cs b/src/Duende.AccessTokenManagement/ClientCredentialsTokenEndpointService.cs index 1adaf15..4e5f6c7 100755 --- a/src/Duende.AccessTokenManagement/ClientCredentialsTokenEndpointService.cs +++ b/src/Duende.AccessTokenManagement/ClientCredentialsTokenEndpointService.cs @@ -23,23 +23,19 @@ public class ClientCredentialsTokenEndpointService : IClientCredentialsTokenEndp private readonly IClientAssertionService _clientAssertionService; private readonly IDPoPKeyStore _dPoPKeyMaterialService; private readonly IDPoPProofService _dPoPProofService; + private readonly ITokenEndpointRetriever _tokenEndpointRetriever; private readonly ILogger _logger; /// /// ctor /// - /// - /// - /// - /// - /// - /// public ClientCredentialsTokenEndpointService( IHttpClientFactory httpClientFactory, IOptionsMonitor options, IClientAssertionService clientAssertionService, IDPoPKeyStore dPoPKeyMaterialService, IDPoPProofService dPoPProofService, + ITokenEndpointRetriever tokenEndpointRetriever, ILogger logger) { _httpClientFactory = httpClientFactory; @@ -47,6 +43,7 @@ public ClientCredentialsTokenEndpointService( _clientAssertionService = clientAssertionService; _dPoPKeyMaterialService = dPoPKeyMaterialService; _dPoPProofService = dPoPProofService; + _tokenEndpointRetriever = tokenEndpointRetriever; _logger = logger; } @@ -58,14 +55,14 @@ public virtual async Task RequestToken( { var client = _options.Get(clientName); - if (string.IsNullOrWhiteSpace(client.TokenEndpoint) || string.IsNullOrEmpty(client.ClientId)) + if ((string.IsNullOrWhiteSpace(client.TokenEndpoint) && string.IsNullOrWhiteSpace(client.Authority))|| string.IsNullOrEmpty(client.ClientId)) { throw new InvalidOperationException("unknown client"); } var request = new ClientCredentialsTokenRequest { - Address = client.TokenEndpoint, + Address = await _tokenEndpointRetriever.GetAsync(client), Scope = client.Scope, ClientId = client.ClientId, ClientSecret = client.ClientSecret, diff --git a/src/Duende.AccessTokenManagement/ClientCredentialsTokenManagementServiceCollectionExtensions.cs b/src/Duende.AccessTokenManagement/ClientCredentialsTokenManagementServiceCollectionExtensions.cs index 0d8c847..d079a87 100644 --- a/src/Duende.AccessTokenManagement/ClientCredentialsTokenManagementServiceCollectionExtensions.cs +++ b/src/Duende.AccessTokenManagement/ClientCredentialsTokenManagementServiceCollectionExtensions.cs @@ -6,6 +6,7 @@ using Duende.AccessTokenManagement; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; namespace Microsoft.Extensions.DependencyInjection; @@ -38,6 +39,7 @@ public static ClientCredentialsTokenManagementBuilder AddClientCredentialsTokenM public static ClientCredentialsTokenManagementBuilder AddClientCredentialsTokenManagement(this IServiceCollection services) { services.TryAddSingleton(); + services.TryAddSingleton(); services.TryAddTransient(); services.TryAddTransient(); diff --git a/src/Duende.AccessTokenManagement/Interfaces/ITokenEndpointRetriever.cs b/src/Duende.AccessTokenManagement/Interfaces/ITokenEndpointRetriever.cs new file mode 100644 index 0000000..1038c2c --- /dev/null +++ b/src/Duende.AccessTokenManagement/Interfaces/ITokenEndpointRetriever.cs @@ -0,0 +1,14 @@ +using System.Threading.Tasks; + +namespace Duende.AccessTokenManagement; + +/// +/// Retrieves the token endpoint either using discovery or static configuration +/// +public interface ITokenEndpointRetriever +{ + /// + /// Gets the token endpoint + /// + Task GetAsync(ClientCredentialsClient client); +} diff --git a/src/Duende.AccessTokenManagement/StringExtensions.cs b/src/Duende.AccessTokenManagement/StringExtensions.cs new file mode 100644 index 0000000..dae0072 --- /dev/null +++ b/src/Duende.AccessTokenManagement/StringExtensions.cs @@ -0,0 +1,24 @@ +// Copyright (c) Brock Allen & Dominick Baier. All rights reserved. +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; + +namespace Duende.AccessTokenManagement; + +// Note that this is duplicated in Duende.AccessTokenManagement.OpenIdConnect, +// but we can't share the code because it is internal. +internal static class StringExtensions +{ + [DebuggerStepThrough] + public static bool IsMissing([NotNullWhen(false)]this string? value) + { + return string.IsNullOrWhiteSpace(value); + } + + [DebuggerStepThrough] + public static bool IsPresent([NotNullWhen(true)]this string? value) + { + return !string.IsNullOrWhiteSpace(value); + } +} \ No newline at end of file diff --git a/src/Duende.AccessTokenManagement/TokenEndpointRetriever.cs b/src/Duende.AccessTokenManagement/TokenEndpointRetriever.cs new file mode 100644 index 0000000..b01f824 --- /dev/null +++ b/src/Duende.AccessTokenManagement/TokenEndpointRetriever.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using IdentityModel.Client; + +namespace Duende.AccessTokenManagement; + +/// +public class TokenEndpointRetriever : ITokenEndpointRetriever +{ + private readonly Dictionary _caches = new(); + + private DiscoveryCache GetDiscoCache(string authority) + { + if (!_caches.ContainsKey(authority)) + { + _caches[authority] = new DiscoveryCache(authority); + } + return _caches[authority]; + } + + /// + public async Task GetAsync(ClientCredentialsClient client) + { + if (client.Authority.IsPresent()) + { + var discoCache = GetDiscoCache(client.Authority); + var disco = await discoCache.GetAsync(); + if(disco.IsError) + { + throw new InvalidOperationException("Failed to retrieve disco"); + } + return disco.TokenEndpoint ?? throw new InvalidOperationException("Disco does not contain token endpoint"); + } + else if (client.TokenEndpoint.IsPresent()) + { + return client.TokenEndpoint; + } + else + { + throw new InvalidOperationException("No token endpoint or authority configured"); + } + + } +} \ No newline at end of file diff --git a/test/Tests/Framework/AppHost.cs b/test/Tests/Framework/AppHost.cs index 2923294..95d6ac6 100644 --- a/test/Tests/Framework/AppHost.cs +++ b/test/Tests/Framework/AppHost.cs @@ -107,6 +107,7 @@ private void ConfigureServices(IServiceCollection services) } }); + services.AddSingleton(new TestTokenEndpointRetriever(_identityServerHost.Url("/connect/token"))); } private void Configure(IApplicationBuilder app) diff --git a/test/Tests/Framework/TestTokenEndpointRetreiver.cs b/test/Tests/Framework/TestTokenEndpointRetreiver.cs new file mode 100644 index 0000000..7b00a73 --- /dev/null +++ b/test/Tests/Framework/TestTokenEndpointRetreiver.cs @@ -0,0 +1,12 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace Duende.AccessTokenManagement.Tests; + +public class TestTokenEndpointRetriever(string tokenEndpoint = "https://identityserver/connect/token") : ITokenEndpointRetriever +{ + public Task GetAsync(ClientCredentialsClient client) + { + return Task.FromResult(tokenEndpoint); + } +} \ No newline at end of file From 658449170e553ba84f311b0deb407d05c5a9ab2c Mon Sep 17 00:00:00 2001 From: Joe DeCock Date: Fri, 3 May 2024 15:42:56 -0500 Subject: [PATCH 2/2] Update client credentials samples to use authority --- samples/Worker/ClientAssertionService.cs | 14 ++++++++------ samples/Worker/Program.cs | 7 +++---- samples/WorkerDI/ClientAssertionService.cs | 16 +++++++++------- .../ClientCredentialsClientConfigureOptions.cs | 4 +--- samples/WorkerDI/Program.cs | 3 ++- 5 files changed, 23 insertions(+), 21 deletions(-) diff --git a/samples/Worker/ClientAssertionService.cs b/samples/Worker/ClientAssertionService.cs index 45f8895..86a4f36 100644 --- a/samples/Worker/ClientAssertionService.cs +++ b/samples/Worker/ClientAssertionService.cs @@ -15,6 +15,7 @@ namespace WorkerService; public class ClientAssertionService : IClientAssertionService { + private readonly ITokenEndpointRetriever _tokenEndpointRetriever; private readonly IOptionsMonitor _options; private static string RsaKey = @@ -35,12 +36,13 @@ public class ClientAssertionService : IClientAssertionService private static SigningCredentials Credential = new (new JsonWebKey(RsaKey), "RS256"); - public ClientAssertionService(IOptionsMonitor options) + public ClientAssertionService(ITokenEndpointRetriever tokenEndpointRetriever, IOptionsMonitor options) { + _tokenEndpointRetriever = tokenEndpointRetriever; _options = options; } - public Task GetClientAssertionAsync(string? clientName = null, TokenRequestParameters? parameters = null) + public async Task GetClientAssertionAsync(string? clientName = null, TokenRequestParameters? parameters = null) { if (clientName == "demo.jwt") { @@ -49,7 +51,7 @@ public ClientAssertionService(IOptionsMonitor options) var descriptor = new SecurityTokenDescriptor { Issuer = options.ClientId, - Audience = options.TokenEndpoint, + Audience = await _tokenEndpointRetriever.GetAsync(options), Expires = DateTime.UtcNow.AddMinutes(1), SigningCredentials = Credential, @@ -64,13 +66,13 @@ public ClientAssertionService(IOptionsMonitor options) var handler = new JsonWebTokenHandler(); var jwt = handler.CreateToken(descriptor); - return Task.FromResult(new ClientAssertion + return new ClientAssertion { Type = OidcConstants.ClientAssertionTypes.JwtBearer, Value = jwt - }); + }; } - return Task.FromResult(null); + return null; } } \ No newline at end of file diff --git a/samples/Worker/Program.cs b/samples/Worker/Program.cs index 51e779c..00f0e6e 100755 --- a/samples/Worker/Program.cs +++ b/samples/Worker/Program.cs @@ -34,7 +34,7 @@ public static IHostBuilder CreateHostBuilder(string[] args) services.AddClientCredentialsTokenManagement() .AddClient("demo", client => { - client.TokenEndpoint = "https://demo.duendesoftware.com/connect/token"; + client.Authority = "https://demo.duendesoftware.com/"; client.ClientId = "m2m.short"; client.ClientSecret = "secret"; @@ -43,8 +43,7 @@ public static IHostBuilder CreateHostBuilder(string[] args) }) .AddClient("demo.dpop", client => { - client.TokenEndpoint = "https://demo.duendesoftware.com/connect/token"; - //client.TokenEndpoint = "https://localhost:5001/connect/token"; + client.Authority = "https://demo.duendesoftware.com/"; client.ClientId = "m2m.dpop"; //client.ClientId = "m2m.dpop.nonce"; @@ -55,7 +54,7 @@ public static IHostBuilder CreateHostBuilder(string[] args) }) .AddClient("demo.jwt", client => { - client.TokenEndpoint = "https://demo.duendesoftware.com/connect/token"; + client.Authority = "https://demo.duendesoftware.com"; client.ClientId = "m2m.short.jwt"; client.Scope = "api"; diff --git a/samples/WorkerDI/ClientAssertionService.cs b/samples/WorkerDI/ClientAssertionService.cs index 676dcae..af51b25 100644 --- a/samples/WorkerDI/ClientAssertionService.cs +++ b/samples/WorkerDI/ClientAssertionService.cs @@ -15,6 +15,7 @@ namespace WorkerService; public class ClientAssertionService : IClientAssertionService { + private readonly ITokenEndpointRetriever _tokenEndpoint; private readonly IOptionsMonitor _options; private static string RsaKey = @@ -35,21 +36,22 @@ public class ClientAssertionService : IClientAssertionService private static SigningCredentials Credential = new (new JsonWebKey(RsaKey), "RS256"); - public ClientAssertionService(IOptionsMonitor options) + public ClientAssertionService(ITokenEndpointRetriever tokenEndpoint, IOptionsMonitor options) { + _tokenEndpoint = tokenEndpoint; _options = options; } - public Task GetClientAssertionAsync(string? clientName = null, TokenRequestParameters? parameters = null) + public async Task GetClientAssertionAsync(string? clientName = null, TokenRequestParameters? parameters = null) { if (clientName == "demo.jwt") { var options = _options.Get(clientName); - + var descriptor = new SecurityTokenDescriptor { Issuer = options.ClientId, - Audience = options.TokenEndpoint, + Audience = await _tokenEndpoint.GetAsync(options), Expires = DateTime.UtcNow.AddMinutes(1), SigningCredentials = Credential, @@ -64,13 +66,13 @@ public ClientAssertionService(IOptionsMonitor options) var handler = new JsonWebTokenHandler(); var jwt = handler.CreateToken(descriptor); - return Task.FromResult(new ClientAssertion + return new ClientAssertion { Type = OidcConstants.ClientAssertionTypes.JwtBearer, Value = jwt - }); + }; } - return Task.FromResult(null); + return null; } } \ No newline at end of file diff --git a/samples/WorkerDI/ClientCredentialsClientConfigureOptions.cs b/samples/WorkerDI/ClientCredentialsClientConfigureOptions.cs index 8d98750..68c67ca 100644 --- a/samples/WorkerDI/ClientCredentialsClientConfigureOptions.cs +++ b/samples/WorkerDI/ClientCredentialsClientConfigureOptions.cs @@ -23,9 +23,7 @@ public void Configure(string? name, ClientCredentialsClient options) { if (name == "demo.jwt") { - var disco = _cache.GetAsync().GetAwaiter().GetResult(); - - options.TokenEndpoint = disco.TokenEndpoint; + options.Authority = "https://demo.duendesoftware.com"; options.ClientId = "m2m.short.jwt"; options.Scope = "api"; } diff --git a/samples/WorkerDI/Program.cs b/samples/WorkerDI/Program.cs index f7b121e..111feac 100755 --- a/samples/WorkerDI/Program.cs +++ b/samples/WorkerDI/Program.cs @@ -37,7 +37,8 @@ public static IHostBuilder CreateHostBuilder(string[] args) // alternative way to add a client services.Configure("demo", client => { - client.TokenEndpoint = "https://demo.duendesoftware.com/connect/token"; + client.Authority = "https://demo.duendesoftware.com/"; + // client.TokenEndpoint = "https://demo.duendesoftware.com/connect/token"; client.ClientId = "m2m.short"; client.ClientSecret = "secret";