diff --git a/README.md b/README.md index cc7c7f7..2a0f1a0 100644 --- a/README.md +++ b/README.md @@ -34,10 +34,10 @@ dotnet add package Auth0Net.DependencyInjection ![Auth0Authentication](https://user-images.githubusercontent.com/975824/128319560-4b859296-44f5-4219-a1b3-8255bf29f1b3.png) -If you're simply using the `AuthenticationApiClient` and nothing else, you can call `AddAuth0AuthenticationClientCore` and pass in your Auth0 Domain. This integration is lightweight and does not support any other features of this library. +If you're simply using the `AuthenticationApiClient` and nothing else, you can call `AddAuth0AuthenticationClient` and pass in your Auth0 Domain. This integration is lightweight and does not support any other features of this library. ```csharp -services.AddAuth0AuthenticationClientCore("your-auth0-domain.auth0.com"); +services.AddAuth0AuthenticationClient("your-auth0-domain.auth0.com"); ``` You can then request the `IAuthenticationApiClient` within your class: @@ -70,10 +70,10 @@ services.AddAuth0AuthenticationClient(config => }); ``` -Add the `ManagementApiClient` with `AddAuth0ManagementClient()` and add the `DelegatingHandler` with `AddManagementAccessToken()` that will attach the Access Token automatically: +Add the `ManagementApiClient` with `AddAuth0ManagementClient()`. The client will attach the Access Token automatically: ```csharp -services.AddAuth0ManagementClient().AddManagementAccessToken(); +services.AddAuth0ManagementClient(); ``` Ensure your Machine-to-Machine application is authorized to request tokens from the Managment API and it has the correct scopes for the features you wish to use. @@ -81,7 +81,6 @@ Ensure your Machine-to-Machine application is authorized to request tokens from You can then request the `IManagementApiClient` (or `IAuthenticationApiClient`) within your services: ```csharp - public class MyAuth0Service : IAuth0Service { private readonly IManagementApiClient _managementApiClient; @@ -93,23 +92,23 @@ public class MyAuth0Service : IAuth0Service ``` - #### Handling Custom Domains +#### Handling Custom Domains -If you're using a custom domain with your Auth0 tenant, you may run into a problem whereby the `audience` of the Management API is being incorrectly set. You can override this via the `Audience` property: +If you're using a custom domain with your Auth0 tenant, and it's being specified when calling `AddAuth0AuthenticationClient`, you will run into a problem whereby the `audience` of the Management API is being incorrectly set. You can override this via the `Audience` property: ```cs -services.AddAuth0ManagementClient() - .AddManagementAccessToken(c => +services.AddAuth0ManagementClient(c => { + // Set the audience to your default Auth0 domain. c.Audience = "my-tenant.au.auth0.com"; }); ``` -### With HttpClient and/or Grpc Services (Machine-To-Machine tokens) +### External HttpClient & Grpc Services (Machine-To-Machine Tokens) ![Auth0AuthenticationAll](https://user-images.githubusercontent.com/975824/128319653-418e0e72-2ddf-4d02-9544-1d60bd523321.png) -**Note:** This feature relies on `services.AddAuth0AuthenticationClient(config => ...)` being called and configured as outlined in the previous scenario. +**Note:** This feature relies on `services.AddAuth0AuthenticationClient(config => ...)` being called and configured as outlined in the previous section. This library includes a delegating handler - effectively middleware for your HttpClient - that will append an access token to all outbound requests. This is useful for calling other services that are protected by Auth0. This integration requires your service implementation to use `IHttpClientFactory` as part of its registration. You can read more about it [here](https://docs.microsoft.com/en-us/aspnet/core/fundamentals/http-requests) @@ -140,29 +139,85 @@ services.AddHttpClient(x=> x.BaseAddress = new Uri("https://MySer .AddAccessToken(config => config.AudienceResolver = request => request.RequestUri.GetLeftPart(UriPartial.Authority)); ``` -## Additional Functionality +### M2M Organizations Support + +This library includes support for [Machine-to-Machine (M2M) Access for Organizations](https://auth0.com/docs/manage-users/organizations/organizations-for-m2m-applications), including static and dynamic scenarios. +This feature is important if your internal or third-party services expect a token to be scoped to a specific Auth0 organization. + +Orgs support must be enabled for your combination of client/api/org(s) before usage. + +#### Static Organization + +Clients that simply require a single organization for a specific client can do so via setting the `Organization` property when configuring the access token: + +```csharp +builder.Services + .AddGrpcClient(x => x.Address = new Uri(builder.Configuration["AspNetCore:Url"]!)) + .AddAccessToken(config => + { + config.Audience = builder.Configuration["AspNetCore:Audience"]; + config.Organization = builder.Configuration["AspNetCore:Audience"]; + }); +``` -### Enhanced Resilience +#### Dynamic Organization via Request Metadata -The default rate-limit behaviour in Auth0.NET is suboptimal, as it uses random backoff rather than reading the rate limit headers returned by Auth0. -This package includes an additional `.AddAuth0RateLimitResilience()` extension that adds improved rate limit handling to the Auth0 clients. -If you're running into rate limit failures, I highly recommend adding this functionality: +If you already include org metadata as part of your network request or via the request options and would like to easily migrate to Org-scoped tokens, you can choose to resolve the organization at runtime via the `OrganizationResolver`: ```csharp -services.AddAuth0ManagementClient() - .AddManagementAccessToken() - .AddAuth0RateLimitResilience(); +builder.Services + .AddGrpcClient(x => x.Address = new Uri(builder.Configuration["AspNetCore:Url"]!)) + .AddAccessToken(config => + { + config.Audience = builder.Configuration["AspNetCore:Audience"]; + config.OrganizationResolver = x => + x.Headers.TryGetValues("org-id", out var values) + ? values.SingleOrDefault() + : null; + }); ``` -When a retry occurs, you should see a warning log similar to: +#### Dynamic Organization via Client Scope (Experimental) + +If your organization source is scoped to the usage of your service, such as an ASP.NET Core request, then you'll want the ability to freely set the Organization. +You can achieve this by injecting your client via `OrganizationScopeFactory` and then creating an organization scope via `.CreateScope`: -`Resilience event occurred. EventName: '"OnRetry"', Source: '"IManagementConnection-RateLimitRetry"/""/"Retry"', Operation Key: 'null', Result: '429'` +```csharp +// Inject the factory around your remote client +private readonly OrganizationScopeFactory _scopeFactory; + +public QueryUsersService(OrganizationScopeFactory scopeFactory) +{ + _scopeFactory = scopeFactory; +} + +public async Task CreateUserAsync(User user, string orgId) +{ + // Create the scope so the MTM token is generated with the current OrgId + using var orgScope = _scopeFactory.CreateScope(orgId); + await orgScope.Client.CreateUser(user, stoppingToken); +} + +``` + +ALWAYS ensure you dispose of the scope when finished. + +There's a few limitations if you're using this functionality, as it uses `AsyncLocal` internally: + +- Never use multiple client scopes at the same time, either with the same or different client types. This will throw an exception. +- Never call any other client that utilizes `.AddAccessToken` within a client scope. This may cause the wrong Organization ID/Name being used for a given request. + +If you have a use-case for either of these items, please open an issue with an example. + +This functionality is marked as experimental, and you must `#pragma warning disable AUTH0_EXPERIMENTAL` to use it. + +## Additional Functionality ### Utility This library exposes a simple string extension, `ToHttpsUrl()`, that can be used to format the naked Auth0 domain sitting in your configuration into a proper URL. -This is identical to `https://{Configuration["Auth0:Domain"]}/` that you usually end up writing _somewhere_ in your `Startup.cs`. +This is identical to `https://{Configuration["Auth0:Domain"]}/` that you usually end up writing _somewhere_ in your `Program.cs`. For example, formatting the domain for the JWT Authority: @@ -183,7 +238,7 @@ Both the authentication and authorization clients are registered as singletons a ### Samples -Both a .NET Generic Host and ASP.NET Core example are available in the [samples](https://github.com/Hawxy/Auth0Net.DependencyInjection/tree/main/samples) directory. +Both a .NET Generic Host and ASP.NET Core examples are available in the [samples](https://github.com/Hawxy/Auth0Net.DependencyInjection/tree/main/samples) directory. ### Internal Cache @@ -193,7 +248,18 @@ An additional 1% of lifetime is removed to protect against clock drift between d In some situations you might want to request an access token from Auth0 manually. You can achieve this by injecting `IAuth0TokenCache` into a class and calling `GetTokenAsync` with the audience of the API you're requesting the token for. -An in-memory-only instance of [FusionCache](https://github.com/ZiggyCreatures/FusionCache) is used as the caching implementation. This instance is _named_ and will not impact other usages of FusionCache. +An in-memory-only instance of [FusionCache](https://github.com/ZiggyCreatures/FusionCache) is used as the caching implementation. This instance is _named_ and will not impact other usages of FusionCache. + +If you want to use your own implementation of FusionCache, specify `FusionCacheInstance` when configurating the authentication client: + +```csharp +services.AddAuth0AuthenticationClient(x => + { + //... + // Use the default FusionCache instance registered via `.AddFusionCache()` + x.FusionCacheInstance = FusionCacheOptions.DefaultCacheName + }); +``` ## Disclaimer diff --git a/samples/Sample.AspNetCore/Program.cs b/samples/Sample.AspNetCore/Program.cs index 624614b..5f33c0e 100644 --- a/samples/Sample.AspNetCore/Program.cs +++ b/samples/Sample.AspNetCore/Program.cs @@ -1,3 +1,4 @@ +using System.Security.Claims; using Auth0.ManagementApi; using Auth0Net.DependencyInjection; using Auth0Net.DependencyInjection.HttpClient; @@ -9,14 +10,13 @@ var builder = WebApplication.CreateBuilder(args); -// An extension method is included to convert a naked auth0 domain (my-tenant.auth0.au.com) to the correct format (https://my-tenant-auth0.au.com/) -string domain = builder.Configuration["Auth0:Domain"]!.ToHttpsUrl(); +var domain = builder.Configuration["Auth0:Domain"]; // Protect your API with authentication as you normally would builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { - options.Authority = domain; + options.Authority = domain!.ToHttpsUrl(); options.Audience = builder.Configuration["Auth0:Audience"]; }); @@ -35,7 +35,7 @@ // Adds the AuthenticationApiClient client and provides configuration to be consumed by the management client, token cache, and IHttpClientBuilder integrations builder.Services.AddAuth0AuthenticationClient(config => { - config.Domain = domain; + config.Domain = domain!; config.ClientId = builder.Configuration["Auth0:ClientId"]; config.ClientSecret = builder.Configuration["Auth0:ClientSecret"]; }); @@ -59,5 +59,16 @@ return user.CurrentPage.Select(x => new Sample.AspNetCore.User(x.UserId, x.Name, x.Email)).ToArray(); }); +app.MapGet("/users/org-scoped", async ([FromServices] IManagementApiClient client, HttpContext context, ILogger logger) => +{ + var orgId = context.User.FindFirstValue("org_id"); + if (!string.IsNullOrEmpty(orgId)) { + logger.LogInformation("Found org {org}", orgId); + } + var user = await client.Users.ListAsync(new ListUsersRequestParameters() { }); + + return user.CurrentPage.Select(x => new Sample.AspNetCore.User(x.UserId, x.Name, x.Email)).ToArray(); +}); + app.Run(); \ No newline at end of file diff --git a/samples/Sample.ConsoleApp/Program.cs b/samples/Sample.ConsoleApp/Program.cs index a20fa2f..975bda8 100644 --- a/samples/Sample.ConsoleApp/Program.cs +++ b/samples/Sample.ConsoleApp/Program.cs @@ -24,7 +24,10 @@ // Works for the Grpc integration too! builder.Services .AddGrpcClient(x => x.Address = new Uri(builder.Configuration["AspNetCore:Url"]!)) - .AddAccessToken(config => config.Audience = builder.Configuration["AspNetCore:Audience"]); + .AddAccessToken(config => + { + config.Audience = builder.Configuration["AspNetCore:Audience"]; + }); builder.Services.AddHostedService(); diff --git a/samples/Sample.ConsoleApp/PumpingBackgroundService.cs b/samples/Sample.ConsoleApp/PumpingBackgroundService.cs index 764aaf4..5e0955f 100644 --- a/samples/Sample.ConsoleApp/PumpingBackgroundService.cs +++ b/samples/Sample.ConsoleApp/PumpingBackgroundService.cs @@ -1,4 +1,6 @@ -using Sample.ConsoleApp.Services; +using Auth0Net.DependencyInjection.HttpClient; +using Auth0Net.DependencyInjection.Organizations; +using Sample.ConsoleApp.Services; using User; namespace Sample.ConsoleApp; @@ -8,13 +10,14 @@ public class PumpingBackgroundService : BackgroundService { private readonly UsersService _usersService; private readonly UserService.UserServiceClient _usersClient; - private readonly ILogger _logger; + private readonly ILogger _logger; - public PumpingBackgroundService(UsersService usersService, UserService.UserServiceClient usersClient, ILogger logger) + public PumpingBackgroundService(UsersService usersService, UserService.UserServiceClient usersClient, ILogger logger) { _usersService = usersService; _usersClient = usersClient; _logger = logger; + } protected override async Task ExecuteAsync(CancellationToken stoppingToken) diff --git a/samples/Sample.ConsoleApp/PumpingBackgroundServiceOrgScoped.cs b/samples/Sample.ConsoleApp/PumpingBackgroundServiceOrgScoped.cs new file mode 100644 index 0000000..87741a1 --- /dev/null +++ b/samples/Sample.ConsoleApp/PumpingBackgroundServiceOrgScoped.cs @@ -0,0 +1,30 @@ +using Auth0Net.DependencyInjection.Organizations; +using Sample.ConsoleApp.Services; +#pragma warning disable AUTH0_EXPERIMENTAL + +namespace Sample.ConsoleApp; + +// Not a realistic example, just using it to hit our API. +public class PumpingBackgroundServiceOrgScoped : BackgroundService +{ + private readonly OrganizationScopeFactory _scopeFactory; + private readonly ILogger _logger; + + public PumpingBackgroundServiceOrgScoped(OrganizationScopeFactory scopeFactory, ILogger logger) + { + _scopeFactory = scopeFactory; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + using var scopedClient = _scopeFactory.CreateScope("org_12345"); + + while (!stoppingToken.IsCancellationRequested) + { + var userHttpClient = await scopedClient.Client.GetUsersAsync(stoppingToken); + _logger.LogInformation("HttpClient got user's email: {email}", userHttpClient?.First().Email); + await Task.Delay(5000, stoppingToken); + } + } +} \ No newline at end of file diff --git a/samples/Sample.ConsoleApp/Services/UsersService.cs b/samples/Sample.ConsoleApp/Services/UsersService.cs index 3a38d63..5ccab92 100644 --- a/samples/Sample.ConsoleApp/Services/UsersService.cs +++ b/samples/Sample.ConsoleApp/Services/UsersService.cs @@ -1,16 +1,16 @@ -using System.Net.Http.Json; +using System.Net.Http.Json; +using Auth0Net.DependencyInjection.HttpClient; namespace Sample.ConsoleApp.Services; public class UsersService { private readonly HttpClient _client; - public UsersService(HttpClient client) { _client = client; } - + public async Task GetUsersAsync(CancellationToken ct) => await _client.GetFromJsonAsync("users", cancellationToken: ct); public record User(string Id, string Name, string Email); diff --git a/src/Auth0Net.DependencyInjection/Auth0Extensions.cs b/src/Auth0Net.DependencyInjection/Auth0Extensions.cs index e5d1f0e..5e5a8af 100644 --- a/src/Auth0Net.DependencyInjection/Auth0Extensions.cs +++ b/src/Auth0Net.DependencyInjection/Auth0Extensions.cs @@ -4,6 +4,7 @@ using Auth0Net.DependencyInjection.Factory; using Auth0Net.DependencyInjection.HttpClient; using Auth0Net.DependencyInjection.Injectables; +using Auth0Net.DependencyInjection.Organizations; using Microsoft.Extensions.DependencyInjection; namespace Auth0Net.DependencyInjection; @@ -89,6 +90,10 @@ private static IHttpClientBuilder AddAuth0AuthenticationClientInternal(this ISer services.AddSingleton(); } + services.AddSingleton(); +#pragma warning disable AUTH0_EXPERIMENTAL + services.AddTransient(typeof(OrganizationScopeFactory<>)); +#pragma warning restore AUTH0_EXPERIMENTAL services.AddSingleton(); return services.AddHttpClient() #if NET8_0 @@ -151,6 +156,6 @@ public static IHttpClientBuilder AddAccessToken(this IHttpClientBuilder builder, throw new ArgumentException("Audience or AudienceResolver must be set"); return builder.AddHttpMessageHandler(provider => - new Auth0TokenHandler(provider.GetRequiredService(), c)); + new Auth0TokenHandler(provider.GetRequiredService(), c, provider.GetRequiredService())); } } \ No newline at end of file diff --git a/src/Auth0Net.DependencyInjection/Cache/Auth0TokenCache.cs b/src/Auth0Net.DependencyInjection/Cache/Auth0TokenCache.cs index 37fc6ad..784edf1 100644 --- a/src/Auth0Net.DependencyInjection/Cache/Auth0TokenCache.cs +++ b/src/Auth0Net.DependencyInjection/Cache/Auth0TokenCache.cs @@ -18,7 +18,7 @@ public sealed class Auth0TokenCache : IAuth0TokenCache private const double TokenExpiryBuffer = 0.01d; - private static string Key(string audience) => $"{nameof(Auth0TokenCache)}-{audience}"; + private static string Key(string audience, string? organization) => string.IsNullOrEmpty(organization) ? $"{nameof(Auth0TokenCache)}-{audience}" : $"{nameof(Auth0TokenCache)}-{audience}-{organization}"; /// /// An implementation of that caches and renews Auth0 Access Tokens @@ -26,20 +26,21 @@ public sealed class Auth0TokenCache : IAuth0TokenCache public Auth0TokenCache(IAuthenticationApiClient client, IFusionCacheProvider provider, ILogger logger, IOptions config) { _client = client; - _cache = !string.IsNullOrEmpty(config.Value.FusionCacheInstance) - ? provider.GetCache(config.Value.FusionCacheInstance) + var cache = !string.IsNullOrEmpty(config.Value.FusionCacheInstance) + ? provider.GetCacheOrNull(config.Value.FusionCacheInstance) : provider.GetCache(Constants.FusionCacheInstance); - + + _cache = cache ?? throw new InvalidOperationException($"Unable to resolve requested FusionCache instance: {config.Value.FusionCacheInstance}. Did you specify the right name?"); _logger = logger; _config = config.Value; } - + /// - public async ValueTask GetTokenAsync(string audience, CancellationToken token = default) + public async ValueTask GetTokenAsync(string audience, string? organization = null, CancellationToken token = default) { _logger.TokenRequested(audience); - return (await _cache.GetOrSetAsync(Key(audience), async (config, ct) => + return (await _cache.GetOrSetAsync(Key(audience, organization), async (config, ct) => { _logger.CacheFetch(audience); @@ -50,6 +51,11 @@ public async ValueTask GetTokenAsync(string audience, CancellationToken Audience = audience }; + if (!string.IsNullOrEmpty(organization)) + { + tokenRequest.Organization = organization; + } + var response = await _client.GetTokenAsync(tokenRequest, ct); var computedExpiry = Math.Ceiling(response.ExpiresIn - response.ExpiresIn * TokenExpiryBuffer); @@ -64,6 +70,9 @@ public async ValueTask GetTokenAsync(string audience, CancellationToken return response.AccessToken; }, token: token))!; } + + /// + public ValueTask GetTokenAsync(string audience, CancellationToken token = default) => GetTokenAsync(audience, null, token); /// public ValueTask GetTokenAsync(Uri audience, CancellationToken token = default) => GetTokenAsync(audience.ToString(), token); diff --git a/src/Auth0Net.DependencyInjection/Cache/IAuth0TokenCache.cs b/src/Auth0Net.DependencyInjection/Cache/IAuth0TokenCache.cs index b144912..0706726 100644 --- a/src/Auth0Net.DependencyInjection/Cache/IAuth0TokenCache.cs +++ b/src/Auth0Net.DependencyInjection/Cache/IAuth0TokenCache.cs @@ -1,7 +1,7 @@ namespace Auth0Net.DependencyInjection.Cache; /// -/// A cache implementation +/// Abstraction for the underlying Auth0 Token cache. /// public interface IAuth0TokenCache { @@ -12,6 +12,15 @@ public interface IAuth0TokenCache /// An optional token that can cancel this request /// The JWT ValueTask GetTokenAsync(string audience, CancellationToken token = default); + + /// + /// Get a JSON Web Token (JWT) Access Token for the requested audience + /// + /// The audience you wish to request the token for. + /// The Auth0 org_id or org_name that should be used for this token. + /// An optional token that can cancel this request + /// The JWT + ValueTask GetTokenAsync(string audience, string? organization = null, CancellationToken token = default); /// /// Get a JSON Web Token (JWT) Access Token for the requested audience diff --git a/src/Auth0Net.DependencyInjection/HttpClient/Auth0TokenHandler.cs b/src/Auth0Net.DependencyInjection/HttpClient/Auth0TokenHandler.cs index 0f67873..73c0c84 100644 --- a/src/Auth0Net.DependencyInjection/HttpClient/Auth0TokenHandler.cs +++ b/src/Auth0Net.DependencyInjection/HttpClient/Auth0TokenHandler.cs @@ -1,7 +1,7 @@ using System.Net.Http; using System.Net.Http.Headers; using Auth0Net.DependencyInjection.Cache; -using Microsoft.Extensions.Options; +using Auth0Net.DependencyInjection.Organizations; namespace Auth0Net.DependencyInjection.HttpClient; @@ -13,16 +13,19 @@ public class Auth0TokenHandler : DelegatingHandler private const string Scheme = "Bearer"; private readonly IAuth0TokenCache _cache; private readonly Auth0TokenHandlerConfig _handlerConfig; + private readonly HttpClientOrganizationAccessor _accessor; /// /// Constructs a new instance of the /// /// An instance of an . /// The configuration for this handler. - public Auth0TokenHandler(IAuth0TokenCache cache, Auth0TokenHandlerConfig handlerConfig) + /// + public Auth0TokenHandler(IAuth0TokenCache cache, Auth0TokenHandlerConfig handlerConfig, HttpClientOrganizationAccessor accessor) { _cache = cache; _handlerConfig = handlerConfig; + _accessor = accessor; } /// @@ -30,7 +33,11 @@ protected override async Task SendAsync(HttpRequestMessage { var audience = _handlerConfig.Audience ?? _handlerConfig.AudienceResolver?.Invoke(request) ?? throw new ArgumentException("Audience cannot be computed"); - request.Headers.Authorization = new AuthenticationHeaderValue(Scheme, await _cache.GetTokenAsync(audience, cancellationToken)); + var org = _accessor.Organization ?? _handlerConfig.Organization ?? _handlerConfig.OrganizationResolver?.Invoke(request); + var token = await _cache.GetTokenAsync(audience, org, cancellationToken); + + request.Headers.Authorization = new AuthenticationHeaderValue(Scheme, token); + return await base.SendAsync(request, cancellationToken); } } \ No newline at end of file diff --git a/src/Auth0Net.DependencyInjection/HttpClient/Auth0TokenHandlerConfig.cs b/src/Auth0Net.DependencyInjection/HttpClient/Auth0TokenHandlerConfig.cs index 491ae9e..3bc3fba 100644 --- a/src/Auth0Net.DependencyInjection/HttpClient/Auth0TokenHandlerConfig.cs +++ b/src/Auth0Net.DependencyInjection/HttpClient/Auth0TokenHandlerConfig.cs @@ -1,4 +1,4 @@ -using System.Net.Http; +using System.Net.Http; namespace Auth0Net.DependencyInjection.HttpClient; @@ -21,4 +21,15 @@ public sealed class Auth0TokenHandlerConfig /// public Func? AudienceResolver { get; set; } + /// + /// + /// + public string? Organization { get; set; } + /// + /// A resolver that will compute the org_name or org_id during the request. + /// + /// + public Func? OrganizationResolver { get; set; } + + } \ No newline at end of file diff --git a/src/Auth0Net.DependencyInjection/Organizations/HttpClientOrganizationAccessor.cs b/src/Auth0Net.DependencyInjection/Organizations/HttpClientOrganizationAccessor.cs new file mode 100644 index 0000000..8056d1b --- /dev/null +++ b/src/Auth0Net.DependencyInjection/Organizations/HttpClientOrganizationAccessor.cs @@ -0,0 +1,19 @@ +namespace Auth0Net.DependencyInjection.Organizations; + +/// +/// Provides a mechanism for setting and retrieving the current organization context +/// using . +/// +public sealed class HttpClientOrganizationAccessor +{ + private readonly AsyncLocal _organization = new(); + + /// + /// Gets or sets the current organization context within the . + /// + public string? Organization + { + get => _organization.Value; + set => _organization.Value = value; + } +} \ No newline at end of file diff --git a/src/Auth0Net.DependencyInjection/Organizations/OrganizationScope.cs b/src/Auth0Net.DependencyInjection/Organizations/OrganizationScope.cs new file mode 100644 index 0000000..b57a36a --- /dev/null +++ b/src/Auth0Net.DependencyInjection/Organizations/OrganizationScope.cs @@ -0,0 +1,32 @@ +namespace Auth0Net.DependencyInjection.Organizations; + +/// +/// Represents a scoped instance tied to a specific organization. This class ensures that +/// the organization context is properly managed and disposed of when no longer needed. +/// +/// Must be created via +/// +/// +/// The type of the client used within the organization scope. +/// +public sealed class OrganizationScope : IDisposable where T : class +{ + private readonly HttpClientOrganizationAccessor _accessor; + + internal OrganizationScope(T client, HttpClientOrganizationAccessor accessor) + { + _accessor = accessor; + Client = client; + } + + /// + /// Gets the client instance associated with the current organization scope. + /// + public T Client { get; } + + /// + public void Dispose() + { + _accessor.Organization = null; + } +} \ No newline at end of file diff --git a/src/Auth0Net.DependencyInjection/Organizations/OrganizationScopeFactory.cs b/src/Auth0Net.DependencyInjection/Organizations/OrganizationScopeFactory.cs new file mode 100644 index 0000000..2179de7 --- /dev/null +++ b/src/Auth0Net.DependencyInjection/Organizations/OrganizationScopeFactory.cs @@ -0,0 +1,62 @@ +using System.Diagnostics.CodeAnalysis; +using Auth0.AuthenticationApi; +using Auth0.ManagementApi; + +namespace Auth0Net.DependencyInjection.Organizations; + +/// +/// Factory class for creating scoped instances of +/// associated with a specified organization. +/// +/// +/// The type of client used within the organization scope. This must be a user-defined remote client. +/// +[Experimental("AUTH0_EXPERIMENTAL")] +public class OrganizationScopeFactory where TClient: class +{ + private readonly TClient _client; + private readonly HttpClientOrganizationAccessor _accessor; + + /// + /// Factory for creating instances of for a specified organization. + /// This factory is designed for use with custom remote clients and cannot be used with Auth0 client types like + /// or . + /// + /// + /// The type of the client used in the organization scope. + /// + public OrganizationScopeFactory(TClient client, HttpClientOrganizationAccessor accessor) + { + if (client is IAuthenticationApiClient or IManagementApiClient) + { + throw new InvalidOperationException($"{nameof(OrganizationScopeFactory)} is designed for use with your own remote clients and cannot be used with Auth0 client types."); + } + + _client = client; + _accessor = accessor; + } + + /// + /// Creates a new instance of associated with the specified organization. + /// This method sets the organization context for the scoped instance. + /// + /// + /// The identifier of the organization to associate with the created scope. + /// + /// + /// A new instance of linked to the specified organization. + /// + public OrganizationScope CreateScope(string organization) + { +#if NET8_0_OR_GREATER + ArgumentNullException.ThrowIfNull(organization); +#endif + if (!string.IsNullOrEmpty(_accessor.Organization)) + throw new InvalidOperationException( + "Attempted to create a nested organization scope. This is unsupported. Please open an issue if you'd find this useful."); + + _accessor.Organization = organization; + return new OrganizationScope(_client, _accessor); + } + +} \ No newline at end of file diff --git a/tests/Auth0Net.DependencyInjection.Tests/CacheTests.cs b/tests/Auth0Net.DependencyInjection.Tests/CacheTests.cs index 2a91f09..4ed63a3 100644 --- a/tests/Auth0Net.DependencyInjection.Tests/CacheTests.cs +++ b/tests/Auth0Net.DependencyInjection.Tests/CacheTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Threading; using System.Threading.Tasks; using Auth0.AuthenticationApi; @@ -59,9 +59,69 @@ public async Task Cache_WorksAsExpected() A.CallTo(() => authClient.GetTokenAsync(A.Ignored, A.Ignored)) .MustHaveHappenedTwiceExactly(); - } + + [Fact] + public async Task Cache_WhenGivenOrgId_PassesOrgIdToTokenRequest() + { + const string orgId = "org_123456"; + + var config = A.Fake>(); + A.CallTo(() => config.Value).Returns(new Auth0Configuration + { + ClientId = Guid.NewGuid().ToString(), + ClientSecret = Guid.NewGuid().ToString(), + Domain = "https://hawxy.au.auth0.com/", + }); + + var authClient = A.Fake(); + A.CallTo(() => authClient.GetTokenAsync(A.Ignored, A.Ignored)) + .Returns(new AccessTokenResponse { AccessToken = "token", ExpiresIn = 60 }); + + var cache = new Auth0TokenCache(authClient, new FusionCacheTestProvider(), new NullLogger(), config); + + await cache.GetTokenAsync("api://my-audience", orgId, TestContext.Current.CancellationToken); + + A.CallTo(() => authClient.GetTokenAsync( + A.That.Matches(r => r.Organization == orgId), + A.Ignored)) + .MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task Cache_DifferentOrganizations_ResultInSeparateCacheEntries() + { + var config = A.Fake>(); + A.CallTo(() => config.Value).Returns(new Auth0Configuration + { + ClientId = Guid.NewGuid().ToString(), + ClientSecret = Guid.NewGuid().ToString(), + Domain = "https://hawxy.au.auth0.com/", + }); + + var authClient = A.Fake(); + A.CallTo(() => authClient.GetTokenAsync(A.Ignored, A.Ignored)) + .Returns(new AccessTokenResponse { AccessToken = "token", ExpiresIn = 300 }); + + var cache = new Auth0TokenCache(authClient, new FusionCacheTestProvider(), new NullLogger(), config); + + const string audience = "api://my-audience"; + + // First call for org_a - should hit the auth client + await cache.GetTokenAsync(audience, "org_a", TestContext.Current.CancellationToken); + // Second call for org_a - should be served from cache + await cache.GetTokenAsync(audience, "org_a", TestContext.Current.CancellationToken); + + // First call for org_b - different org, should hit the auth client again + await cache.GetTokenAsync(audience, "org_b", TestContext.Current.CancellationToken); + // Second call for org_b - should be served from cache + await cache.GetTokenAsync(audience, "org_b", TestContext.Current.CancellationToken); + + // Auth client should have been called exactly twice - once per unique org + A.CallTo(() => authClient.GetTokenAsync(A.Ignored, A.Ignored)) + .MustHaveHappened(2, Times.Exactly); + } [Fact] public async Task Cache_UsesFusionCacheInstance_WhenConfigured() @@ -121,7 +181,7 @@ public IFusionCache GetCacheOrNull(string cacheName) private sealed class CapturingFusionCacheProvider : IFusionCacheProvider { - public string? LastRequestedCacheName { get; private set; } + public string LastRequestedCacheName { get; private set; } public IFusionCache GetCache(string cacheName) { @@ -131,7 +191,8 @@ public IFusionCache GetCache(string cacheName) public IFusionCache GetCacheOrNull(string cacheName) { - throw new NotImplementedException(); + LastRequestedCacheName = cacheName; + return new FusionCache(new FusionCacheOptions()); } } } \ No newline at end of file diff --git a/tests/Auth0Net.DependencyInjection.Tests/OrganizationAccessorTests.cs b/tests/Auth0Net.DependencyInjection.Tests/OrganizationAccessorTests.cs new file mode 100644 index 0000000..2e3c4de --- /dev/null +++ b/tests/Auth0Net.DependencyInjection.Tests/OrganizationAccessorTests.cs @@ -0,0 +1,213 @@ +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Auth0Net.DependencyInjection.Cache; +using Auth0Net.DependencyInjection.HttpClient; +using Auth0Net.DependencyInjection.Organizations; +using FakeItEasy; +using Xunit; + +namespace Auth0Net.DependencyInjection.Tests; + +public class OrganizationAccessorTests +{ + [Fact] + public async Task OrganizationScope_AppliesOrganizationToTokenHandler() + { + var cache = A.Fake(); + A.CallTo(() => cache.GetTokenAsync(A._, A._, A._)) + .Returns("access-token"); + + var accessor = new HttpClientOrganizationAccessor { Organization = "org-from-accessor" }; + var config = new Auth0TokenHandlerConfig { Audience = "api://test" }; + + using var invoker = BuildInvoker(cache, config, accessor); + await invoker.SendAsync(new HttpRequestMessage(HttpMethod.Get, "https://example.com"), CancellationToken.None); + + A.CallTo(() => cache.GetTokenAsync("api://test", "org-from-accessor", A._)) + .MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task TokenHandler_UsesConfigOrganization_WhenAccessorIsEmpty() + { + var cache = A.Fake(); + A.CallTo(() => cache.GetTokenAsync(A._, A._, A._)) + .Returns("access-token"); + + var accessor = new HttpClientOrganizationAccessor(); + var config = new Auth0TokenHandlerConfig { Audience = "api://test", Organization = "org-from-config" }; + + using var invoker = BuildInvoker(cache, config, accessor); + await invoker.SendAsync(new HttpRequestMessage(HttpMethod.Get, "https://example.com"), CancellationToken.None); + + A.CallTo(() => cache.GetTokenAsync("api://test", "org-from-config", A._)) + .MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task TokenHandler_AccessorOrganizationTakesPrecedenceOverConfig() + { + var cache = A.Fake(); + A.CallTo(() => cache.GetTokenAsync(A._, A._, A._)) + .Returns("access-token"); + + var accessor = new HttpClientOrganizationAccessor { Organization = "org-from-accessor" }; + var config = new Auth0TokenHandlerConfig { Audience = "api://test", Organization = "org-from-config" }; + + using var invoker = BuildInvoker(cache, config, accessor); + await invoker.SendAsync(new HttpRequestMessage(HttpMethod.Get, "https://example.com"), CancellationToken.None); + + A.CallTo(() => cache.GetTokenAsync("api://test", "org-from-accessor", A._)) + .MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task TokenHandler_UsesOrganizationResolver_WhenAccessorAndConfigAreEmpty() + { + var cache = A.Fake(); + A.CallTo(() => cache.GetTokenAsync(A._, A._, A._)) + .Returns("access-token"); + + var accessor = new HttpClientOrganizationAccessor(); + var config = new Auth0TokenHandlerConfig + { + Audience = "api://test", + OrganizationResolver = _ => "org-from-resolver" + }; + + using var invoker = BuildInvoker(cache, config, accessor); + await invoker.SendAsync(new HttpRequestMessage(HttpMethod.Get, "https://example.com"), CancellationToken.None); + + A.CallTo(() => cache.GetTokenAsync("api://test", "org-from-resolver", A._)) + .MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task TokenHandler_ConfigOrganizationTakesPrecedenceOverResolver() + { + var cache = A.Fake(); + A.CallTo(() => cache.GetTokenAsync(A._, A._, A._)) + .Returns("access-token"); + + var accessor = new HttpClientOrganizationAccessor(); + var config = new Auth0TokenHandlerConfig + { + Audience = "api://test", + Organization = "org-from-config", + OrganizationResolver = _ => "org-from-resolver" + }; + + using var invoker = BuildInvoker(cache, config, accessor); + await invoker.SendAsync(new HttpRequestMessage(HttpMethod.Get, "https://example.com"), CancellationToken.None); + + A.CallTo(() => cache.GetTokenAsync("api://test", "org-from-config", A._)) + .MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task TokenHandler_AccessorTakesPrecedenceOverResolver() + { + var cache = A.Fake(); + A.CallTo(() => cache.GetTokenAsync(A._, A._, A._)) + .Returns("access-token"); + + var accessor = new HttpClientOrganizationAccessor { Organization = "org-from-accessor" }; + var config = new Auth0TokenHandlerConfig + { + Audience = "api://test", + OrganizationResolver = _ => "org-from-resolver" + }; + + using var invoker = BuildInvoker(cache, config, accessor); + await invoker.SendAsync(new HttpRequestMessage(HttpMethod.Get, "https://example.com"), CancellationToken.None); + + A.CallTo(() => cache.GetTokenAsync("api://test", "org-from-accessor", A._)) + .MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task TokenHandler_OrganizationResolver_ReceivesHttpRequestMessage() + { + var cache = A.Fake(); + A.CallTo(() => cache.GetTokenAsync(A._, A._, A._)) + .Returns("access-token"); + + HttpRequestMessage? capturedRequest = null; + + var accessor = new HttpClientOrganizationAccessor(); + var config = new Auth0TokenHandlerConfig + { + Audience = "api://test", + OrganizationResolver = req => + { + capturedRequest = req; + return "org-resolved"; + } + }; + + using var invoker = BuildInvoker(cache, config, accessor); + var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api/resource"); + await invoker.SendAsync(request, CancellationToken.None); + + Assert.NotNull(capturedRequest); + Assert.Equal("https://example.com/api/resource", capturedRequest.RequestUri!.ToString()); + } + + [Fact] + public async Task TokenHandler_OrganizationResolver_ReturnsNull_PassesNullToCache() + { + var cache = A.Fake(); + A.CallTo(() => cache.GetTokenAsync(A._, A._, A._)) + .Returns("access-token"); + + var accessor = new HttpClientOrganizationAccessor(); + var config = new Auth0TokenHandlerConfig + { + Audience = "api://test", + OrganizationResolver = _ => null + }; + + using var invoker = BuildInvoker(cache, config, accessor); + await invoker.SendAsync(new HttpRequestMessage(HttpMethod.Get, "https://example.com"), CancellationToken.None); + + A.CallTo(() => cache.GetTokenAsync("api://test", null, A._)) + .MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task TokenHandler_NoOrganizationConfigured_PassesNullToCache() + { + var cache = A.Fake(); + A.CallTo(() => cache.GetTokenAsync(A._, A._, A._)) + .Returns("access-token"); + + var accessor = new HttpClientOrganizationAccessor(); + var config = new Auth0TokenHandlerConfig { Audience = "api://test" }; + + using var invoker = BuildInvoker(cache, config, accessor); + await invoker.SendAsync(new HttpRequestMessage(HttpMethod.Get, "https://example.com"), CancellationToken.None); + + A.CallTo(() => cache.GetTokenAsync("api://test", null, A._)) + .MustHaveHappenedOnceExactly(); + } + + private static HttpMessageInvoker BuildInvoker( + IAuth0TokenCache cache, + Auth0TokenHandlerConfig config, + HttpClientOrganizationAccessor accessor) + { + var handler = new Auth0TokenHandler(cache, config, accessor) + { + InnerHandler = new StubInnerHandler() + }; + return new HttpMessageInvoker(handler); + } + + private sealed class StubInnerHandler : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); + } +} diff --git a/tests/Auth0Net.DependencyInjection.Tests/OrganizationScopeFactoryTests.cs b/tests/Auth0Net.DependencyInjection.Tests/OrganizationScopeFactoryTests.cs new file mode 100644 index 0000000..996e83e --- /dev/null +++ b/tests/Auth0Net.DependencyInjection.Tests/OrganizationScopeFactoryTests.cs @@ -0,0 +1,66 @@ +using System; +using Auth0.AuthenticationApi; +using Auth0.ManagementApi; +using Auth0Net.DependencyInjection.Organizations; +using FakeItEasy; +using Xunit; + +namespace Auth0Net.DependencyInjection.Tests; +#pragma warning disable AUTH0_EXPERIMENTAL +public class OrganizationScopeFactoryTests +{ + [Fact] + public void OrganizationScopeFactory_ThrowsForAuthenticationApiClient() + { + var client = A.Fake(); + var accessor = new HttpClientOrganizationAccessor(); + + Assert.Throws(() => + new OrganizationScopeFactory(client, accessor)); + + } + + [Fact] + public void OrganizationScopeFactory_ThrowsForManagementApiClient() + { + var client = A.Fake(); + var accessor = new HttpClientOrganizationAccessor(); + + Assert.Throws(() => + new OrganizationScopeFactory(client, accessor)); + } + + [Fact] + public void OrganizationScopeFactory_CreateScope_ThrowsInNestedScope() + { + var client = new TestClient(); + var accessor = new HttpClientOrganizationAccessor(); + + var factory = new OrganizationScopeFactory(client, accessor); + var scope = factory.CreateScope("org-123"); + Assert.Throws(() => factory.CreateScope("org-123")); + + scope.Dispose(); + Assert.Null(accessor.Organization); + } + + [Fact] + public void OrganizationScopeFactory_CreateScope_SetsOrganizationOnAccessorAndExposesClient() + { + var client = new TestClient(); + var accessor = new HttpClientOrganizationAccessor(); + + var factory = new OrganizationScopeFactory(client, accessor); + var scope = factory.CreateScope("org-123"); + + Assert.Equal("org-123", accessor.Organization); + Assert.Same(client, scope.Client); + + scope.Dispose(); + Assert.Null(accessor.Organization); + } + + + + private sealed class TestClient { } +}