Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Authority to ClientCredentials options #102

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
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
14 changes: 8 additions & 6 deletions samples/Worker/ClientAssertionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ namespace WorkerService;

public class ClientAssertionService : IClientAssertionService
{
private readonly ITokenEndpointRetriever _tokenEndpointRetriever;
private readonly IOptionsMonitor<ClientCredentialsClient> _options;

private static string RsaKey =
Expand All @@ -35,12 +36,13 @@ public class ClientAssertionService : IClientAssertionService

private static SigningCredentials Credential = new (new JsonWebKey(RsaKey), "RS256");

public ClientAssertionService(IOptionsMonitor<ClientCredentialsClient> options)
public ClientAssertionService(ITokenEndpointRetriever tokenEndpointRetriever, IOptionsMonitor<ClientCredentialsClient> options)
{
_tokenEndpointRetriever = tokenEndpointRetriever;
_options = options;
}

public Task<ClientAssertion?> GetClientAssertionAsync(string? clientName = null, TokenRequestParameters? parameters = null)
public async Task<ClientAssertion?> GetClientAssertionAsync(string? clientName = null, TokenRequestParameters? parameters = null)
{
if (clientName == "demo.jwt")
{
Expand All @@ -49,7 +51,7 @@ public ClientAssertionService(IOptionsMonitor<ClientCredentialsClient> options)
var descriptor = new SecurityTokenDescriptor
{
Issuer = options.ClientId,
Audience = options.TokenEndpoint,
Audience = await _tokenEndpointRetriever.GetAsync(options),
Expires = DateTime.UtcNow.AddMinutes(1),
SigningCredentials = Credential,

Expand All @@ -64,13 +66,13 @@ public ClientAssertionService(IOptionsMonitor<ClientCredentialsClient> options)
var handler = new JsonWebTokenHandler();
var jwt = handler.CreateToken(descriptor);

return Task.FromResult<ClientAssertion?>(new ClientAssertion
return new ClientAssertion
{
Type = OidcConstants.ClientAssertionTypes.JwtBearer,
Value = jwt
});
};
}

return Task.FromResult<ClientAssertion?>(null);
return null;
}
}
7 changes: 3 additions & 4 deletions samples/Worker/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand All @@ -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";
Expand Down
16 changes: 9 additions & 7 deletions samples/WorkerDI/ClientAssertionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ namespace WorkerService;

public class ClientAssertionService : IClientAssertionService
{
private readonly ITokenEndpointRetriever _tokenEndpoint;
private readonly IOptionsMonitor<ClientCredentialsClient> _options;

private static string RsaKey =
Expand All @@ -35,21 +36,22 @@ public class ClientAssertionService : IClientAssertionService

private static SigningCredentials Credential = new (new JsonWebKey(RsaKey), "RS256");

public ClientAssertionService(IOptionsMonitor<ClientCredentialsClient> options)
public ClientAssertionService(ITokenEndpointRetriever tokenEndpoint, IOptionsMonitor<ClientCredentialsClient> options)
{
_tokenEndpoint = tokenEndpoint;
_options = options;
}

public Task<ClientAssertion?> GetClientAssertionAsync(string? clientName = null, TokenRequestParameters? parameters = null)
public async Task<ClientAssertion?> 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),
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For most usage, you just set the authority and let discovery happen in the background. But sometimes you do need to know what the token endpoint is - for example, here when we make a client assertion, we need to use the token endpoint as the audience. This is slightly more complex than just reading the option as we used to - notably this uses a new service and is an async operation. I don't think we can really avoid this though, because invoking the discovery endpoint *is async. At one point, we talked about doing this in a PostConfigureOptions, but that doesn't support asynchronicity.

Expires = DateTime.UtcNow.AddMinutes(1),
SigningCredentials = Credential,

Expand All @@ -64,13 +66,13 @@ public ClientAssertionService(IOptionsMonitor<ClientCredentialsClient> options)
var handler = new JsonWebTokenHandler();
var jwt = handler.CreateToken(descriptor);

return Task.FromResult<ClientAssertion?>(new ClientAssertion
return new ClientAssertion
{
Type = OidcConstants.ClientAssertionTypes.JwtBearer,
Value = jwt
});
};
}

return Task.FromResult<ClientAssertion?>(null);
return null;
}
}
4 changes: 1 addition & 3 deletions samples/WorkerDI/ClientCredentialsClientConfigureOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
Expand Down
3 changes: 2 additions & 1 deletion samples/WorkerDI/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ public static IHostBuilder CreateHostBuilder(string[] args)
// alternative way to add a client
services.Configure<ClientCredentialsClient>("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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -19,5 +21,4 @@ public static bool IsPresent([NotNullWhen(true)]this string? value)
{
return !string.IsNullOrWhiteSpace(value);
}

}
12 changes: 11 additions & 1 deletion src/Duende.AccessTokenManagement/ClientCredentialsClient.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -11,6 +15,12 @@ namespace Duende.AccessTokenManagement;
/// </summary>
public class ClientCredentialsClient
{
/// <summary>
/// The address of the OAuth authority. If this is set, the TokenEndpoint
/// will be set using discovery.
/// </summary>
public string? Authority { get; set; }

/// <summary>
/// The address of the token endpoint
/// </summary>
Expand Down Expand Up @@ -60,4 +70,4 @@ public class ClientCredentialsClient
/// The string representation of the JSON web key to use for DPoP.
/// </summary>
public string? DPoPJsonWebKey { get; set; }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,30 +23,27 @@ public class ClientCredentialsTokenEndpointService : IClientCredentialsTokenEndp
private readonly IClientAssertionService _clientAssertionService;
private readonly IDPoPKeyStore _dPoPKeyMaterialService;
private readonly IDPoPProofService _dPoPProofService;
private readonly ITokenEndpointRetriever _tokenEndpointRetriever;
private readonly ILogger<ClientCredentialsTokenEndpointService> _logger;

/// <summary>
/// ctor
/// </summary>
/// <param name="httpClientFactory"></param>
/// <param name="clientAssertionService"></param>
/// <param name="dPoPKeyMaterialService"></param>
/// <param name="dPoPProofService"></param>
/// <param name="logger"></param>
/// <param name="options"></param>
public ClientCredentialsTokenEndpointService(
IHttpClientFactory httpClientFactory,
IOptionsMonitor<ClientCredentialsClient> options,
IClientAssertionService clientAssertionService,
IDPoPKeyStore dPoPKeyMaterialService,
IDPoPProofService dPoPProofService,
ITokenEndpointRetriever tokenEndpointRetriever,
ILogger<ClientCredentialsTokenEndpointService> logger)
{
_httpClientFactory = httpClientFactory;
_options = options;
_clientAssertionService = clientAssertionService;
_dPoPKeyMaterialService = dPoPKeyMaterialService;
_dPoPProofService = dPoPProofService;
_tokenEndpointRetriever = tokenEndpointRetriever;
_logger = logger;
}

Expand All @@ -58,14 +55,14 @@ public virtual async Task<ClientCredentialsToken> 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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using Duende.AccessTokenManagement;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

namespace Microsoft.Extensions.DependencyInjection;

Expand Down Expand Up @@ -38,6 +39,7 @@ public static ClientCredentialsTokenManagementBuilder AddClientCredentialsTokenM
public static ClientCredentialsTokenManagementBuilder AddClientCredentialsTokenManagement(this IServiceCollection services)
{
services.TryAddSingleton<ITokenRequestSynchronization, TokenRequestSynchronization>();
services.TryAddSingleton<ITokenEndpointRetriever, TokenEndpointRetriever>();

services.TryAddTransient<IClientCredentialsTokenManagementService, ClientCredentialsTokenManagementService>();
services.TryAddTransient<IClientCredentialsTokenCache, DistributedClientCredentialsTokenCache>();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using System.Threading.Tasks;

namespace Duende.AccessTokenManagement;

/// <summary>
/// Retrieves the token endpoint either using discovery or static configuration
/// </summary>
public interface ITokenEndpointRetriever
{
/// <summary>
/// Gets the token endpoint
/// </summary>
Task<string> GetAsync(ClientCredentialsClient client);
}
24 changes: 24 additions & 0 deletions src/Duende.AccessTokenManagement/StringExtensions.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
45 changes: 45 additions & 0 deletions src/Duende.AccessTokenManagement/TokenEndpointRetriever.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using IdentityModel.Client;

namespace Duende.AccessTokenManagement;

/// <inheritdoc/>
public class TokenEndpointRetriever : ITokenEndpointRetriever
{
private readonly Dictionary<string, DiscoveryCache> _caches = new();

private DiscoveryCache GetDiscoCache(string authority)
{
if (!_caches.ContainsKey(authority))
{
_caches[authority] = new DiscoveryCache(authority);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should support DiscoveryPolicy and Timeout here.

}
return _caches[authority];
}

/// <inheritdoc/>
public async Task<string> 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");
}

}
}
1 change: 1 addition & 0 deletions test/Tests/Framework/AppHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ private void ConfigureServices(IServiceCollection services)
}
});

services.AddSingleton<ITokenEndpointRetriever>(new TestTokenEndpointRetriever(_identityServerHost.Url("/connect/token")));
}

private void Configure(IApplicationBuilder app)
Expand Down
12 changes: 12 additions & 0 deletions test/Tests/Framework/TestTokenEndpointRetreiver.cs
Original file line number Diff line number Diff line change
@@ -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<string> GetAsync(ClientCredentialsClient client)
{
return Task.FromResult(tokenEndpoint);
}
}
Loading