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

Consider adding Action<IServiceProvider,ClientCredentialsClient> to ClientCredentialsTokenManagementBuilder.AddClient #1494

Closed
JotaCe14 opened this issue Nov 27, 2024 · 5 comments
Assignees

Comments

@JotaCe14
Copy link

JotaCe14 commented Nov 27, 2024

Which version of Duende.AccessTokenManagement are you using?

3.0.1

Which version of .NET are you using?

NET 8.0

Describe the bug

When adding clients to AddClientCredentialsTokenManagement() I would like to use some services like IOptions<> to configure
my ClientCredentialsClient properties dynamically.

To Reproduce

  1. Add AddClientCredentialsTokenManagement to ServiceCollection
  2. Add AddClient to ClientCredentialsTokenManagementBuilder
  3. Try to use some services in Action

Expected behavior

services
.AddClientCredentialsTokenManagement()
.AddClient("token-client", (serviceProvider, client) =>
{
//HERE IS WHERE I WOULD LIKE TO GET SOME SERVICES LIKE:
var service = serviceProvider.GetRequiredService();
var settings = serviceProvider.GetRequiredService<IOptions>().Value;
client.ClientId = settings.ClientId;
client.ClientSecret = settings.ClientSecret;
client.TokenEndpoint = service.GetUrl(CancellationToken.None);
client.Scope = settings .ClientScope;
});

@amiriltd
Copy link

amiriltd commented Nov 27, 2024

Per the documentation

Customizing Client Credentials Token Management

Or use the IConfigureNamedOptions if you need access to the DI container during registration, e.g.:

public class ClientCredentialsClientConfigureOptions : IConfigureNamedOptions<ClientCredentialsClient>
{
    private readonly DiscoveryCache _cache;

    public ClientCredentialsClientConfigureOptions(DiscoveryCache cache)
    {
        _cache = cache;
    }
    
    public void Configure(string name, ClientCredentialsClient options)
    {
        if (name == "invoices")
        {
            var disco = _cache.GetAsync().GetAwaiter().GetResult();

            options.TokenEndpoint = disco.TokenEndpoint;
            
            client.ClientId = "4a632e2e-0466-4e5a-a094-0455c6105f57";
   	    client.ClientSecret = "e8ae294a-d5f3-4907-88fa-c83b3546b70c";

    	    client.Scope = "list";
    	    client.Resource = "urn:invoices";
        }
    }
}

..and register the config options e.g. like this:

services.AddClientCredentialsTokenManagement();

services.AddSingleton(new DiscoveryCache("https://sts.company.com"));
services.AddSingleton<IConfigureOptions<ClientCredentialsClient>, 	
	ClientCredentialsClientConfigureOptions>();

The way you implement your ClientCredentialsClientConfigurationOptions class is up to you.

Here is how I implemented it.

  1. First I created a ClientCredentialSettings Class. I created my own so that I could configure DPoP as an option if the API resource requires it.
  public class ClientCredentialSettings
  {
      /// <summary>
      /// The discovery url of the token endpoint
      /// </summary>
      public string? Authority { get; set; }
      /// <summary>
      /// The client ID 
      /// </summary>
      public string? ClientId { get; set; }

      /// <summary>
      /// The static (shared) client secret
      /// </summary>
      public string? ClientSecret { get; set; }

      /// <summary>
      /// The scope
      /// </summary>
      public string? Scope { get; set; }

      public bool EnableDPoP { get; set; } = false;

  }
  1. Second, I create a IDictionary<string, ClientCredentialSettings> object in the pipeline ie Program.cs and bind it to my configuration of ClientCredentialSettings in appSettings.js or KeyVault, UserSecrets, etc :
Dictionary<string, ClientCredentialSettings> dict = new();
builder.Configuration.GetSection("ClientCredentialSettings.ClientCredentialOptions").Bind(dict);
builder.Services.AddSingleton(typeof(IDictionary<string, ClientCredentialSettings>), dict);
  1. Third, and most importantly the ClientCredentialsClientConfigurationOptions class. In my implementation I called it BffClientConfigurationOptions. I am using IDistributedCache interface in my pipeline and the DiscoveryCache store provided by Duende:
DiscoveryCache discoveryCache = new(builder.Configuration.GetValue<string>(IdentitySettings.Authority));
builder.Services.AddSingleton(typeof(DiscoveryCache), discoveryCache);

builder.Services.AddSingleton<IConfigureOptions<ClientCredentialsClient>, BffClientConfigurationOptions>();

BffClientConfigurationOptions:

  public class BffClientConfigurationOptions : IConfigureNamedOptions<ClientCredentialsClient>
  {
      const string CacheKeyPrefix = "DistributedDPoPKeyStore";
      const char CacheKeySeparator = ':';
      private readonly DiscoveryCache _discoveryCache;
      private readonly IDictionary<string, ClientCredentialSettings> _dict;
      private readonly IDistributedCache _cache;
      private readonly ILogger _logger;


      public BffClientConfigurationOptions(DiscoveryCache discoveryCache, IDictionary<string, ClientCredentialSettings> dict, IDistributedCache cache, ILoggerFactory loggerFactory)
      {
          _discoveryCache = discoveryCache;
          _dict = dict;
          _cache = cache;
          _logger = loggerFactory.CreateLogger<BffClientConfigurationOptions>();

      }
      public void Configure(string? name, ClientCredentialsClient options)
      {

          ClientCredentialSettings _settings = new();

          if (!string.IsNullOrEmpty(name) && _dict.ContainsKey(name) && _dict.First(j => j.Key == name).Value.EnableDPoP)
          {
              _settings = _dict.First(j => j.Key == name).Value;
              //check for key in cache if not there create one and store it
              CancellationToken cancellationToken = default;
              var cacheKey = GenerateCacheKey(name);
              var entry = _cache.GetStringAsync(cacheKey, token: cancellationToken).GetAwaiter().GetResult();
              _logger.LogDebug("Cache hit for DPoP nonce for Cleint Name: {clientName}", name);
              if (entry is null)
              {
                  entry = CreateDPoPKey();

                  StoreDPoPKey(name, cacheKey, entry, cancellationToken);
              }

              options.DPoPJsonWebKey = entry;
          }
          //var disco = _discoveryCache.GetAsync().GetAwaiter().GetResult();

          //options.TokenEndpoint = disco.TokenEndpoint;

          options.TokenEndpoint = _settings.Authority;
          options.ClientId = _settings.ClientId;
          options.ClientSecret = _settings.ClientSecret;

          options.Scope = _settings.Scope;
      }

      public void Configure(ClientCredentialsClient options) => Configure(Options.DefaultName, options);

      private void StoreDPoPKey(string clientName, string cacheKey, string data, CancellationToken cancellationToken)
      {
          var cacheExpiration = DateTimeOffset.UtcNow.AddHours(1);

          var entryOptions = new DistributedCacheEntryOptions
          {
              AbsoluteExpiration = cacheExpiration
          };

          _logger.LogTrace("Caching DPoP nonce for Client Name: {clientName}. Expiration: {expiration}", clientName, cacheExpiration);

          _cache.SetStringAsync(cacheKey, data, entryOptions, token: cancellationToken).GetAwaiter().GetResult();
      }
      private static string CreateDPoPKey()
      {
          var key = new RsaSecurityKey(RSA.Create(2048));
          var jwk = JsonWebKeyConverter.ConvertFromRSASecurityKey(key);
          jwk.Alg = "PS256";
          var jwkJson = JsonSerializer.Serialize(jwk);
          return jwkJson;
      }

      protected virtual string GenerateCacheKey(string clientName)
      {
          return $"{CacheKeyPrefix}{CacheKeySeparator}{clientName}{CacheKeySeparator}{clientName}";
      }



  }

@JotaCe14
Copy link
Author

JotaCe14 commented Dec 2, 2024

Okey and what would be the equivalent for two different named clients? I mean, i have to configure two different clients for two different services, how do I configure it with that approach.

For example:

services
    .AddClientCredentialsTokenManagement()
    .AddClient("client-1", client =>
    {
        client.ClientId = options?.ClientId;
        client.ClientSecret = options?.ClientSecret;
        client.TokenEndpoint = options?.TokenEndpoint;
        client.Scope = options?.Scope;
    })
    .AddClient("client-2", client =>
    {
        client.ClientId = otherOptions?.ClientId;
        client.ClientSecret = otherOptions?.ClientSecret;
        client.TokenEndpoint = otherOptions?.TokenEndpoint;
        client.Scope = otherOptions?.Scope;
    });

@amiriltd
Copy link

amiriltd commented Dec 2, 2024

So my Clients ("Client-1","Client-2", "Client-3", etc...) would be in some configuration like appsettings.json or preferably app configuration services. The json would look like this: appsettings.json

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Information"
    }
  },
  "AllowedHosts": "*",
  "com.Sample.Bff": {
    "ClientCredentialSettings": {
      "Client-1": {
        "Authority": "https://demo.duendesoftware.com",
        "ClientId": "CatalogClient",
        "ClientSecret": "secret",
        "EnableDPoP": "true",
        "Resource": "Catalog",
        "Scope": "Catalog.Bff"
      },
      "Client-2": {
        "Authority": "https://demo.duendesoftware.com",
        "ClientId": "PaymentClient",
        "ClientSecret": "secretp",
        "EnableDPoP": "true",
        "Resource": "Payment",
        "Scope": "Payment.Bff"
      },
      "Client-3": {
        "Authority": "https://demo.duendesoftware.com",
        "ClientId": "UrlShortenerClient",
        "ClientSecret": "secretu",
        "EnableDPoP": "false",
        "Resource": "UrlShortener",
        "Scope": "UrlShortener.Bff"
      }
    }
  }

Add ClientCredentialsTokenManagement :

builder  
        .Services.AddClientCredentialsTokenManagement();

The ClientCredentialSettings section of the configuration will form the IDictionary<string, ClientCredentialSettings> class in the pipeline.

Dictionary<string, ClientCredentialSettings> dict = new();
builder.Configuration.GetSection("com.Sample.Bff:ClientCredentialSettings").Bind(dict);
builder.Services.AddSingleton(typeof(IDictionary<string, ClientCredentialSettings>), dict);

Next add your HttpClients and attach the tokenHandler based on the client you want to use for that HttpClient:

builder.Services.AddHttpClient<CatalogService>(configureClient =>
        {
            configureClient.BaseAddress = new Uri("https//catalog.exampleapp.com");
            configureClient.DefaultRequestHeaders.Add(HeaderNames.Accept, "*/*");
            configureClient.DefaultRequestHeaders.Add(HeaderNames.UserAgent, "catalog client marquee");
        })
         .AddClientCredentialsTokenHandler("Client-1")
        ;


builder.Services.AddHttpClient<PaymentService>(configureClient =>
        {
            configureClient.BaseAddress = new Uri("https//payment.exampleapp.com");
            configureClient.DefaultRequestHeaders.Add(HeaderNames.Accept, "*/*");
            configureClient.DefaultRequestHeaders.Add(HeaderNames.UserAgent, "payment client");
        })
         .AddClientCredentialsTokenHandler("Client-2")
        ;

When the AddClientCredentialsTokenHandler resolves, it will create a client based on the entry "Client-1". It goes without saying that if you try to create AddClientCredentialsTokenHandler("Client-7") and you do not have and entry where the key value is "Client-7" it will fail.

So finally now my CatalogService and PaymentServices should be all set and can call the client without having to think about authentication and authorization.

public class CatalogService
{
    private readonly ILogger _logger;
    private readonly IHttpClientFactory _httpClientFactory;

    public CatalogService(ILoggerFactory loggerFactory, IHttpClientFactory httpClientFactory)
    {

        _logger = loggerFactory.CreateLogger<CatalogService>();
        _httpClientFactory = httpClientFactory;


    }


    public async Task Run()
    {

              var url = "/catalog";
                 

                    HttpClient client = _httpClientFactory.CreateClient(nameof(CatalogService));
                    try
                    {
                        HttpResponseMessage response = await client.GetAsync(url);
                        if (response.IsSuccessStatusCode)
                        {
                          //So something
                        }
                    }
                    catch (Exception ex)
                    {
                        _logger.LogInformation(new EventId(-100000, name: "CatalogService.Error"), ex, "{TimerFunctionName} Processor excuted at {DateOfTImer} ", new object[] { "CatalogService.Error", DateTime.UtcNow });

                    }
                    finally
                    {
                        client.Dispose();
                    }

     
    }
}

@RolandGuijt
Copy link

@JotaCe14 Is this resolved for you or would you like to add a comment? If there's nothing more to add I'd like to close the issue.

@JotaCe14
Copy link
Author

JotaCe14 commented Dec 5, 2024

It's okey for me, thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

3 participants