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"; 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