From e843ac65210465d362fe7ac5164a379e81208c2a Mon Sep 17 00:00:00 2001 From: JT Date: Thu, 3 Jul 2025 16:48:42 +0800 Subject: [PATCH 01/13] Add Orgs support to token cache --- .../Cache/Auth0TokenCache.cs | 10 +++++++++- .../Cache/IAuth0TokenCache.cs | 11 ++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/Auth0Net.DependencyInjection/Cache/Auth0TokenCache.cs b/src/Auth0Net.DependencyInjection/Cache/Auth0TokenCache.cs index 37fc6ad..ef34258 100644 --- a/src/Auth0Net.DependencyInjection/Cache/Auth0TokenCache.cs +++ b/src/Auth0Net.DependencyInjection/Cache/Auth0TokenCache.cs @@ -35,7 +35,10 @@ public Auth0TokenCache(IAuthenticationApiClient client, IFusionCacheProvider pro } /// - public async ValueTask GetTokenAsync(string audience, CancellationToken token = default) + public ValueTask GetTokenAsync(string audience, CancellationToken token = default) => GetTokenAsync(audience, null, token); + + /// + public async ValueTask GetTokenAsync(string audience, string? organization = null, CancellationToken token = default) { _logger.TokenRequested(audience); @@ -50,6 +53,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); 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 From 68e0bc0291766b104e14ba83e1fb32ce6e19131c Mon Sep 17 00:00:00 2001 From: JT Date: Thu, 3 Jul 2025 18:43:10 +0800 Subject: [PATCH 02/13] WIP --- .../Services/UsersService.cs | 11 ++++++- .../HttpClient/Auth0TokenHandler.cs | 10 +++++- .../HttpClient/Auth0TokenHandlerConfig.cs | 13 +++++++- .../HttpClient/HttpClientOptionsExtensions.cs | 33 +++++++++++++++++++ .../HttpClient/OptionsConstants.cs | 6 ++++ 5 files changed, 70 insertions(+), 3 deletions(-) create mode 100644 src/Auth0Net.DependencyInjection/HttpClient/HttpClientOptionsExtensions.cs create mode 100644 src/Auth0Net.DependencyInjection/HttpClient/OptionsConstants.cs diff --git a/samples/Sample.ConsoleApp/Services/UsersService.cs b/samples/Sample.ConsoleApp/Services/UsersService.cs index 3a38d63..470e2e4 100644 --- a/samples/Sample.ConsoleApp/Services/UsersService.cs +++ b/samples/Sample.ConsoleApp/Services/UsersService.cs @@ -1,4 +1,5 @@ -using System.Net.Http.Json; +using System.Net.Http.Json; +using Auth0Net.DependencyInjection.HttpClient; namespace Sample.ConsoleApp.Services; @@ -11,6 +12,14 @@ public UsersService(HttpClient client) _client = client; } + public async Task GetUsersInOrgAsync(string orgId, CancellationToken ct) + { + var message = new HttpRequestMessage(HttpMethod.Get, "users"); + message.Options.SetOrganization(orgId); + var response = await _client.SendAsync(message, ct); + return await response.Content.ReadFromJsonAsync(cancellationToken: ct); + } + 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/HttpClient/Auth0TokenHandler.cs b/src/Auth0Net.DependencyInjection/HttpClient/Auth0TokenHandler.cs index 0f67873..25df342 100644 --- a/src/Auth0Net.DependencyInjection/HttpClient/Auth0TokenHandler.cs +++ b/src/Auth0Net.DependencyInjection/HttpClient/Auth0TokenHandler.cs @@ -30,7 +30,15 @@ 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)); + #if NET8_0_OR_GREATER + var org = request.Options.GetOrganization() ?? _handlerConfig.Organization ?? _handlerConfig.AudienceResolver?.Invoke(request); + var token = await _cache.GetTokenAsync(audience, org, cancellationToken); + #else + var token = await _cache.GetTokenAsync(audience, cancellationToken); + #endif + + 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..19e8154 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/HttpClient/HttpClientOptionsExtensions.cs b/src/Auth0Net.DependencyInjection/HttpClient/HttpClientOptionsExtensions.cs new file mode 100644 index 0000000..5f59e8e --- /dev/null +++ b/src/Auth0Net.DependencyInjection/HttpClient/HttpClientOptionsExtensions.cs @@ -0,0 +1,33 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Auth0Net.DependencyInjection.HttpClient; +#if NET8_0_OR_GREATER +/// +/// +/// +public static class HttpClientOptionsExtensions +{ + private static readonly HttpRequestOptionsKey OrgKey = new("auth0_org"); + + /// + /// + /// + /// + /// + public static void SetOrganization(this HttpRequestOptions options, string organization) + { + options.Set(OrgKey, organization); + } + + /// + /// + /// + /// + /// + public static string? GetOrganization(this HttpRequestOptions options) + { + options.TryGetValue(OrgKey, out var organization); + return organization; + } +} +#endif \ No newline at end of file diff --git a/src/Auth0Net.DependencyInjection/HttpClient/OptionsConstants.cs b/src/Auth0Net.DependencyInjection/HttpClient/OptionsConstants.cs new file mode 100644 index 0000000..78d84f5 --- /dev/null +++ b/src/Auth0Net.DependencyInjection/HttpClient/OptionsConstants.cs @@ -0,0 +1,6 @@ +namespace Auth0Net.DependencyInjection.HttpClient; + +internal static class OptionsConstants +{ + public const string Auth0Org = nameof(Auth0Org); +} \ No newline at end of file From fc753181b9caaf34fcd19c056179fbfbb635410b Mon Sep 17 00:00:00 2001 From: JT Date: Thu, 20 Nov 2025 18:30:04 +0800 Subject: [PATCH 03/13] WIP --- Auth0Net.DependencyInjection.sln | 6 ++ README.md | 51 +++++++++++- samples/Sample.AspNetCore.Orgs/Program.cs | 78 +++++++++++++++++++ .../Properties/launchSettings.json | 23 ++++++ .../Sample.AspNetCore.Orgs.csproj | 18 +++++ .../Sample.AspNetCore.Orgs.http | 6 ++ .../appsettings.Development.json | 8 ++ .../Sample.AspNetCore.Orgs/appsettings.json | 9 +++ samples/Sample.AspNetCore/Program.cs | 4 +- samples/Sample.ConsoleApp/Program.cs | 9 ++- .../PumpingBackgroundService.cs | 9 ++- .../PumpingBackgroundServiceOrgScoped.cs | 30 +++++++ .../Services/UsersService.cs | 9 --- .../Auth0Extensions.cs | 7 +- .../HttpClient/Auth0ManagementTokenHandler.cs | 31 ++++++++ .../HttpClient/Auth0TokenHandler.cs | 9 ++- .../HttpClient/Auth0TokenHandlerConfig.cs | 2 +- .../HttpClient/HttpClientOptionsExtensions.cs | 33 -------- .../HttpClient/OptionsConstants.cs | 6 -- .../HttpClientOrganizationAccessor.cs | 12 +++ .../Organizations/OrganizationScope.cs | 18 +++++ .../Organizations/OrganizationScopeFactory.cs | 45 +++++++++++ .../CacheTests.cs | 46 ++++++++--- .../OrgTests.cs | 6 ++ .../OrganizationScopeHandlerTests.cs | 15 ++++ 25 files changed, 419 insertions(+), 71 deletions(-) create mode 100644 samples/Sample.AspNetCore.Orgs/Program.cs create mode 100644 samples/Sample.AspNetCore.Orgs/Properties/launchSettings.json create mode 100644 samples/Sample.AspNetCore.Orgs/Sample.AspNetCore.Orgs.csproj create mode 100644 samples/Sample.AspNetCore.Orgs/Sample.AspNetCore.Orgs.http create mode 100644 samples/Sample.AspNetCore.Orgs/appsettings.Development.json create mode 100644 samples/Sample.AspNetCore.Orgs/appsettings.json create mode 100644 samples/Sample.ConsoleApp/PumpingBackgroundServiceOrgScoped.cs create mode 100644 src/Auth0Net.DependencyInjection/HttpClient/Auth0ManagementTokenHandler.cs delete mode 100644 src/Auth0Net.DependencyInjection/HttpClient/HttpClientOptionsExtensions.cs delete mode 100644 src/Auth0Net.DependencyInjection/HttpClient/OptionsConstants.cs create mode 100644 src/Auth0Net.DependencyInjection/Organizations/HttpClientOrganizationAccessor.cs create mode 100644 src/Auth0Net.DependencyInjection/Organizations/OrganizationScope.cs create mode 100644 src/Auth0Net.DependencyInjection/Organizations/OrganizationScopeFactory.cs create mode 100644 tests/Auth0Net.DependencyInjection.Tests/OrgTests.cs create mode 100644 tests/Auth0Net.DependencyInjection.Tests/OrganizationScopeHandlerTests.cs diff --git a/Auth0Net.DependencyInjection.sln b/Auth0Net.DependencyInjection.sln index 659d137..ab89481 100644 --- a/Auth0Net.DependencyInjection.sln +++ b/Auth0Net.DependencyInjection.sln @@ -18,6 +18,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution README.md = README.md EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sample.AspNetCore.Orgs", "samples\Sample.AspNetCore.Orgs\Sample.AspNetCore.Orgs.csproj", "{6D5A076F-AB91-4FEC-827D-512ADEAC99D9}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -42,6 +44,10 @@ Global {021A8A83-5A9E-4727-B081-ADFA1EDB0F0F}.Debug|Any CPU.Build.0 = Debug|Any CPU {021A8A83-5A9E-4727-B081-ADFA1EDB0F0F}.Release|Any CPU.ActiveCfg = Release|Any CPU {021A8A83-5A9E-4727-B081-ADFA1EDB0F0F}.Release|Any CPU.Build.0 = Release|Any CPU + {6D5A076F-AB91-4FEC-827D-512ADEAC99D9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6D5A076F-AB91-4FEC-827D-512ADEAC99D9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6D5A076F-AB91-4FEC-827D-512ADEAC99D9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6D5A076F-AB91-4FEC-827D-512ADEAC99D9}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/README.md b/README.md index cc7c7f7..db60056 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ services.AddAuth0ManagementClient() }); ``` -### 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) @@ -140,6 +140,55 @@ services.AddHttpClient(x=> x.BaseAddress = new Uri("https://MySer .AddAccessToken(config => config.AudienceResolver = request => request.RequestUri.GetLeftPart(UriPartial.Authority)); ``` + +### 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. + +#### 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"]; + }); +``` + +#### Dynamic Organization via Request Metadata + +If you already include org metadata as part of your network request or via the request options, you can choose to resolve the organization at runtime via the `OrganizationResolver`: + +```csharp +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; + }); +``` + +#### 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 likely 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`: + +There's a few caveats if you're using this functionality, as it utilizes an `AsyncLocal` internally: + +- Never use multiple client scopes at the same time, either with the same or different client types. +- Never call any other client that utilizes `.AddAccessToken` within a client scope. + +Doing any of the above is likely to result in the wrong Organization ID/Name being used for a given request. + ## Additional Functionality ### Enhanced Resilience diff --git a/samples/Sample.AspNetCore.Orgs/Program.cs b/samples/Sample.AspNetCore.Orgs/Program.cs new file mode 100644 index 0000000..e141098 --- /dev/null +++ b/samples/Sample.AspNetCore.Orgs/Program.cs @@ -0,0 +1,78 @@ +using Auth0Net.DependencyInjection; +using Auth0Net.DependencyInjection.HttpClient; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authorization; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi +builder.Services.AddOpenApi(); + +// 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(); + +// Protect your API with authentication as you normally would +builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.Authority = domain; + options.Audience = builder.Configuration["Auth0:Audience"]; + }); + +// We'll require all endpoints to be authorized by default +builder.Services.AddAuthorizationBuilder() + .SetFallbackPolicy(new AuthorizationPolicyBuilder() + .RequireAuthenticatedUser() + .Build()); + + +// 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.ClientId = builder.Configuration["Auth0:ClientId"]; + config.ClientSecret = builder.Configuration["Auth0:ClientSecret"]; +}); + +// Adds the ManagementApiClient with automatic injection of the management token based on the configuration set above. +builder.Services.AddAuth0ManagementClient().AddManagementAccessToken(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); +} + +app.UseHttpsRedirection(); + +app.UseAuthentication(); +app.UseAuthorization(); + +var summaries = new[] +{ + "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" +}; + +app.MapGet("/weatherforecast", () => + { + var forecast = Enumerable.Range(1, 5).Select(index => + new WeatherForecast + ( + DateOnly.FromDateTime(DateTime.Now.AddDays(index)), + Random.Shared.Next(-20, 55), + summaries[Random.Shared.Next(summaries.Length)] + )) + .ToArray(); + return forecast; + }) + .WithName("GetWeatherForecast"); + +app.Run(); + +record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) +{ + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); +} \ No newline at end of file diff --git a/samples/Sample.AspNetCore.Orgs/Properties/launchSettings.json b/samples/Sample.AspNetCore.Orgs/Properties/launchSettings.json new file mode 100644 index 0000000..0c61188 --- /dev/null +++ b/samples/Sample.AspNetCore.Orgs/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5030", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7129;http://localhost:5030", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/samples/Sample.AspNetCore.Orgs/Sample.AspNetCore.Orgs.csproj b/samples/Sample.AspNetCore.Orgs/Sample.AspNetCore.Orgs.csproj new file mode 100644 index 0000000..6342763 --- /dev/null +++ b/samples/Sample.AspNetCore.Orgs/Sample.AspNetCore.Orgs.csproj @@ -0,0 +1,18 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + + diff --git a/samples/Sample.AspNetCore.Orgs/Sample.AspNetCore.Orgs.http b/samples/Sample.AspNetCore.Orgs/Sample.AspNetCore.Orgs.http new file mode 100644 index 0000000..47d6e9b --- /dev/null +++ b/samples/Sample.AspNetCore.Orgs/Sample.AspNetCore.Orgs.http @@ -0,0 +1,6 @@ +@Sample.AspNetCore.Orgs_HostAddress = http://localhost:5030 + +GET {{Sample.AspNetCore.Orgs_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/samples/Sample.AspNetCore.Orgs/appsettings.Development.json b/samples/Sample.AspNetCore.Orgs/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/samples/Sample.AspNetCore.Orgs/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/samples/Sample.AspNetCore.Orgs/appsettings.json b/samples/Sample.AspNetCore.Orgs/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/samples/Sample.AspNetCore.Orgs/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/samples/Sample.AspNetCore/Program.cs b/samples/Sample.AspNetCore/Program.cs index 624614b..d4928b7 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; @@ -52,8 +53,9 @@ app.MapGrpcService(); -app.MapGet("/users", async ([FromServices] IManagementApiClient client) => +app.MapGet("/users", async ([FromServices] IManagementApiClient client, HttpContext context) => { + var orgId = context.User.FindFirstValue("org_id"); var user = await client.Users.ListAsync(new ListUsersRequestParameters() { }); return user.CurrentPage.Select(x => new Sample.AspNetCore.User(x.UserId, x.Name, x.Email)).ToArray(); diff --git a/samples/Sample.ConsoleApp/Program.cs b/samples/Sample.ConsoleApp/Program.cs index a20fa2f..1b02cf5 100644 --- a/samples/Sample.ConsoleApp/Program.cs +++ b/samples/Sample.ConsoleApp/Program.cs @@ -24,7 +24,14 @@ // 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"]; + config.OrganizationResolver = x => + x.Headers.TryGetValues("org-id", out var values) + ? values.SingleOrDefault() + : null; + }); 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 470e2e4..5ccab92 100644 --- a/samples/Sample.ConsoleApp/Services/UsersService.cs +++ b/samples/Sample.ConsoleApp/Services/UsersService.cs @@ -6,19 +6,10 @@ namespace Sample.ConsoleApp.Services; public class UsersService { private readonly HttpClient _client; - public UsersService(HttpClient client) { _client = client; } - - public async Task GetUsersInOrgAsync(string orgId, CancellationToken ct) - { - var message = new HttpRequestMessage(HttpMethod.Get, "users"); - message.Options.SetOrganization(orgId); - var response = await _client.SendAsync(message, ct); - return await response.Content.ReadFromJsonAsync(cancellationToken: ct); - } public async Task GetUsersAsync(CancellationToken ct) => await _client.GetFromJsonAsync("users", cancellationToken: ct); 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/HttpClient/Auth0ManagementTokenHandler.cs b/src/Auth0Net.DependencyInjection/HttpClient/Auth0ManagementTokenHandler.cs new file mode 100644 index 0000000..f548e82 --- /dev/null +++ b/src/Auth0Net.DependencyInjection/HttpClient/Auth0ManagementTokenHandler.cs @@ -0,0 +1,31 @@ +using System.Net.Http; +using System.Net.Http.Headers; +using Auth0Net.DependencyInjection.Cache; +using Microsoft.Extensions.Options; + +namespace Auth0Net.DependencyInjection.HttpClient; + +internal sealed class Auth0ManagementTokenHandler : DelegatingHandler +{ + private const string Scheme = "Bearer"; + private readonly IAuth0TokenCache _cache; + private readonly IOptions _auth0Configuration; + private readonly Auth0ManagementTokenConfiguration _managementConfiguration; + + public Auth0ManagementTokenHandler(IAuth0TokenCache cache, IOptions auth0Configuration, Auth0ManagementTokenConfiguration managementConfiguration) + { + _cache = cache; + _auth0Configuration = auth0Configuration; + _managementConfiguration = managementConfiguration; + } + + /// + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var token = await _cache.GetTokenAsync(_managementConfiguration.Audience ?? _auth0Configuration.Value.Domain, 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/Auth0TokenHandler.cs b/src/Auth0Net.DependencyInjection/HttpClient/Auth0TokenHandler.cs index 25df342..32a5ae9 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; } /// @@ -31,7 +34,7 @@ protected override async Task SendAsync(HttpRequestMessage var audience = _handlerConfig.Audience ?? _handlerConfig.AudienceResolver?.Invoke(request) ?? throw new ArgumentException("Audience cannot be computed"); #if NET8_0_OR_GREATER - var org = request.Options.GetOrganization() ?? _handlerConfig.Organization ?? _handlerConfig.AudienceResolver?.Invoke(request); + var org = _accessor.Organization ?? _handlerConfig.Organization ?? _handlerConfig.AudienceResolver?.Invoke(request); var token = await _cache.GetTokenAsync(audience, org, cancellationToken); #else var token = await _cache.GetTokenAsync(audience, cancellationToken); diff --git a/src/Auth0Net.DependencyInjection/HttpClient/Auth0TokenHandlerConfig.cs b/src/Auth0Net.DependencyInjection/HttpClient/Auth0TokenHandlerConfig.cs index 19e8154..3bc3fba 100644 --- a/src/Auth0Net.DependencyInjection/HttpClient/Auth0TokenHandlerConfig.cs +++ b/src/Auth0Net.DependencyInjection/HttpClient/Auth0TokenHandlerConfig.cs @@ -29,7 +29,7 @@ public sealed class Auth0TokenHandlerConfig /// A resolver that will compute the org_name or org_id during the request. /// /// - public Func? OrganizationResolver { get; set; } + public Func? OrganizationResolver { get; set; } } \ No newline at end of file diff --git a/src/Auth0Net.DependencyInjection/HttpClient/HttpClientOptionsExtensions.cs b/src/Auth0Net.DependencyInjection/HttpClient/HttpClientOptionsExtensions.cs deleted file mode 100644 index 5f59e8e..0000000 --- a/src/Auth0Net.DependencyInjection/HttpClient/HttpClientOptionsExtensions.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Diagnostics.CodeAnalysis; - -namespace Auth0Net.DependencyInjection.HttpClient; -#if NET8_0_OR_GREATER -/// -/// -/// -public static class HttpClientOptionsExtensions -{ - private static readonly HttpRequestOptionsKey OrgKey = new("auth0_org"); - - /// - /// - /// - /// - /// - public static void SetOrganization(this HttpRequestOptions options, string organization) - { - options.Set(OrgKey, organization); - } - - /// - /// - /// - /// - /// - public static string? GetOrganization(this HttpRequestOptions options) - { - options.TryGetValue(OrgKey, out var organization); - return organization; - } -} -#endif \ No newline at end of file diff --git a/src/Auth0Net.DependencyInjection/HttpClient/OptionsConstants.cs b/src/Auth0Net.DependencyInjection/HttpClient/OptionsConstants.cs deleted file mode 100644 index 78d84f5..0000000 --- a/src/Auth0Net.DependencyInjection/HttpClient/OptionsConstants.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Auth0Net.DependencyInjection.HttpClient; - -internal static class OptionsConstants -{ - public const string Auth0Org = nameof(Auth0Org); -} \ 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..7a9e200 --- /dev/null +++ b/src/Auth0Net.DependencyInjection/Organizations/HttpClientOrganizationAccessor.cs @@ -0,0 +1,12 @@ +namespace Auth0Net.DependencyInjection.Organizations; + +public sealed class HttpClientOrganizationAccessor +{ + private readonly AsyncLocal _organization = new(); + + 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..53708fc --- /dev/null +++ b/src/Auth0Net.DependencyInjection/Organizations/OrganizationScope.cs @@ -0,0 +1,18 @@ +namespace Auth0Net.DependencyInjection.Organizations; + +public class OrganizationScope : IDisposable where T: class +{ + private readonly HttpClientOrganizationAccessor _accessor; + public OrganizationScope(T client, HttpClientOrganizationAccessor accessor) + { + _accessor = accessor; + Client = client; + } + + 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..5fdda14 --- /dev/null +++ b/src/Auth0Net.DependencyInjection/Organizations/OrganizationScopeFactory.cs @@ -0,0 +1,45 @@ +using System.Diagnostics.CodeAnalysis; +using Auth0.AuthenticationApi; +using Auth0.ManagementApi; + +namespace Auth0Net.DependencyInjection.Organizations; + +/// +/// +/// +/// +[Experimental("AUTH0_EXPERIMENTAL")] +public class OrganizationScopeFactory where TClient: class +{ + private readonly TClient _client; + private readonly HttpClientOrganizationAccessor _accessor; + + /// + /// + /// + /// + /// + /// + 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; + } + + /// + /// + /// + /// + /// + public OrganizationScope CreateScope(string organization) + { + _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..410bc88 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,32 +59,54 @@ public async Task Cache_WorksAsExpected() A.CallTo(() => authClient.GetTokenAsync(A.Ignored, A.Ignored)) .MustHaveHappenedTwiceExactly(); - } - + [Fact] - public async Task Cache_UsesFusionCacheInstance_WhenConfigured() + public async Task Cache_WhenGivenOrgId_ReturnsOrgId() { - const string customCacheName = "my-custom-cache"; - 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/", - FusionCacheInstance = customCacheName }); var authClient = A.Fake(); - A.CallTo(() => authClient.GetTokenAsync(A.Ignored, A.Ignored)) - .Returns(new AccessTokenResponse { AccessToken = "token", ExpiresIn = 60 }); - var provider = new CapturingFusionCacheProvider(); - _ = new Auth0TokenCache(authClient, provider, new NullLogger(), config); + var accessTokenFirst = Guid.NewGuid().ToString(); - Assert.Equal(customCacheName, provider.LastRequestedCacheName); + A.CallTo(() => authClient.GetTokenAsync(A.Ignored, A.Ignored)).Returns( + new AccessTokenResponse + { + AccessToken = accessTokenFirst, + ExpiresIn = 1 + }); + + + var cache = new Auth0TokenCache(authClient, new FusionCacheTestProvider(), new NullLogger(), config); + + var key = "api://my-audience"; + var resFirst = await cache.GetTokenAsync(key, TestContext.Current.CancellationToken); + Assert.Equal(accessTokenFirst, resFirst); + await Task.Delay(1000, TestContext.Current.CancellationToken); + + + var accessTokenSecond = Guid.NewGuid().ToString(); + + A.CallTo(() => authClient.GetTokenAsync(A.Ignored, A.Ignored)).Returns( + new AccessTokenResponse + { + AccessToken = accessTokenSecond, + ExpiresIn = 1 + }); + + var resSecond = await cache.GetTokenAsync(key, TestContext.Current.CancellationToken); + Assert.Equal(accessTokenSecond, resSecond); + + A.CallTo(() => authClient.GetTokenAsync(A.Ignored, A.Ignored)) + .MustHaveHappenedTwiceExactly(); } [Fact] diff --git a/tests/Auth0Net.DependencyInjection.Tests/OrgTests.cs b/tests/Auth0Net.DependencyInjection.Tests/OrgTests.cs new file mode 100644 index 0000000..e4cc141 --- /dev/null +++ b/tests/Auth0Net.DependencyInjection.Tests/OrgTests.cs @@ -0,0 +1,6 @@ +namespace Auth0Net.DependencyInjection.Tests; + +public class OrgTests +{ + +} \ No newline at end of file diff --git a/tests/Auth0Net.DependencyInjection.Tests/OrganizationScopeHandlerTests.cs b/tests/Auth0Net.DependencyInjection.Tests/OrganizationScopeHandlerTests.cs new file mode 100644 index 0000000..02d79c8 --- /dev/null +++ b/tests/Auth0Net.DependencyInjection.Tests/OrganizationScopeHandlerTests.cs @@ -0,0 +1,15 @@ +using System.Threading.Tasks; +using Xunit; + +namespace Auth0Net.DependencyInjection.Tests; + +public class OrganizationScopeHandlerTests +{ + [Fact] + public async Task OrganizationScope_AppliesOrganizationToTokenHandler() + { + + } + +} + \ No newline at end of file From a7ddb4bc30e487095e827b63b5e2a96175f0240e Mon Sep 17 00:00:00 2001 From: JT Date: Thu, 2 Apr 2026 20:17:33 +0800 Subject: [PATCH 04/13] Rebase + tests --- Auth0Net.DependencyInjection.sln | 6 -- samples/Sample.AspNetCore.Orgs/Program.cs | 78 ----------------- .../Properties/launchSettings.json | 23 ----- .../Sample.AspNetCore.Orgs.csproj | 18 ---- .../Sample.AspNetCore.Orgs.http | 6 -- .../appsettings.Development.json | 8 -- .../Sample.AspNetCore.Orgs/appsettings.json | 9 -- samples/Sample.AspNetCore/Program.cs | 12 +-- samples/Sample.ConsoleApp/Program.cs | 4 - .../Cache/Auth0TokenCache.cs | 8 +- .../HttpClient/Auth0ManagementTokenHandler.cs | 31 ------- .../CacheTests.cs | 56 +++++++------ .../OrgTests.cs | 6 -- .../OrganizationAccessorTests.cs | 83 +++++++++++++++++++ .../OrganizationScopeFactoryTests.cs | 55 ++++++++++++ .../OrganizationScopeHandlerTests.cs | 15 ---- 16 files changed, 179 insertions(+), 239 deletions(-) delete mode 100644 samples/Sample.AspNetCore.Orgs/Program.cs delete mode 100644 samples/Sample.AspNetCore.Orgs/Properties/launchSettings.json delete mode 100644 samples/Sample.AspNetCore.Orgs/Sample.AspNetCore.Orgs.csproj delete mode 100644 samples/Sample.AspNetCore.Orgs/Sample.AspNetCore.Orgs.http delete mode 100644 samples/Sample.AspNetCore.Orgs/appsettings.Development.json delete mode 100644 samples/Sample.AspNetCore.Orgs/appsettings.json delete mode 100644 src/Auth0Net.DependencyInjection/HttpClient/Auth0ManagementTokenHandler.cs delete mode 100644 tests/Auth0Net.DependencyInjection.Tests/OrgTests.cs create mode 100644 tests/Auth0Net.DependencyInjection.Tests/OrganizationAccessorTests.cs create mode 100644 tests/Auth0Net.DependencyInjection.Tests/OrganizationScopeFactoryTests.cs delete mode 100644 tests/Auth0Net.DependencyInjection.Tests/OrganizationScopeHandlerTests.cs diff --git a/Auth0Net.DependencyInjection.sln b/Auth0Net.DependencyInjection.sln index ab89481..659d137 100644 --- a/Auth0Net.DependencyInjection.sln +++ b/Auth0Net.DependencyInjection.sln @@ -18,8 +18,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution README.md = README.md EndProjectSection EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sample.AspNetCore.Orgs", "samples\Sample.AspNetCore.Orgs\Sample.AspNetCore.Orgs.csproj", "{6D5A076F-AB91-4FEC-827D-512ADEAC99D9}" -EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -44,10 +42,6 @@ Global {021A8A83-5A9E-4727-B081-ADFA1EDB0F0F}.Debug|Any CPU.Build.0 = Debug|Any CPU {021A8A83-5A9E-4727-B081-ADFA1EDB0F0F}.Release|Any CPU.ActiveCfg = Release|Any CPU {021A8A83-5A9E-4727-B081-ADFA1EDB0F0F}.Release|Any CPU.Build.0 = Release|Any CPU - {6D5A076F-AB91-4FEC-827D-512ADEAC99D9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {6D5A076F-AB91-4FEC-827D-512ADEAC99D9}.Debug|Any CPU.Build.0 = Debug|Any CPU - {6D5A076F-AB91-4FEC-827D-512ADEAC99D9}.Release|Any CPU.ActiveCfg = Release|Any CPU - {6D5A076F-AB91-4FEC-827D-512ADEAC99D9}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/samples/Sample.AspNetCore.Orgs/Program.cs b/samples/Sample.AspNetCore.Orgs/Program.cs deleted file mode 100644 index e141098..0000000 --- a/samples/Sample.AspNetCore.Orgs/Program.cs +++ /dev/null @@ -1,78 +0,0 @@ -using Auth0Net.DependencyInjection; -using Auth0Net.DependencyInjection.HttpClient; -using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.AspNetCore.Authorization; - -var builder = WebApplication.CreateBuilder(args); - -// Add services to the container. -// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi -builder.Services.AddOpenApi(); - -// 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(); - -// Protect your API with authentication as you normally would -builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) - .AddJwtBearer(options => - { - options.Authority = domain; - options.Audience = builder.Configuration["Auth0:Audience"]; - }); - -// We'll require all endpoints to be authorized by default -builder.Services.AddAuthorizationBuilder() - .SetFallbackPolicy(new AuthorizationPolicyBuilder() - .RequireAuthenticatedUser() - .Build()); - - -// 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.ClientId = builder.Configuration["Auth0:ClientId"]; - config.ClientSecret = builder.Configuration["Auth0:ClientSecret"]; -}); - -// Adds the ManagementApiClient with automatic injection of the management token based on the configuration set above. -builder.Services.AddAuth0ManagementClient().AddManagementAccessToken(); - -var app = builder.Build(); - -// Configure the HTTP request pipeline. -if (app.Environment.IsDevelopment()) -{ - app.MapOpenApi(); -} - -app.UseHttpsRedirection(); - -app.UseAuthentication(); -app.UseAuthorization(); - -var summaries = new[] -{ - "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" -}; - -app.MapGet("/weatherforecast", () => - { - var forecast = Enumerable.Range(1, 5).Select(index => - new WeatherForecast - ( - DateOnly.FromDateTime(DateTime.Now.AddDays(index)), - Random.Shared.Next(-20, 55), - summaries[Random.Shared.Next(summaries.Length)] - )) - .ToArray(); - return forecast; - }) - .WithName("GetWeatherForecast"); - -app.Run(); - -record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) -{ - public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); -} \ No newline at end of file diff --git a/samples/Sample.AspNetCore.Orgs/Properties/launchSettings.json b/samples/Sample.AspNetCore.Orgs/Properties/launchSettings.json deleted file mode 100644 index 0c61188..0000000 --- a/samples/Sample.AspNetCore.Orgs/Properties/launchSettings.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/launchsettings.json", - "profiles": { - "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": false, - "applicationUrl": "http://localhost:5030", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": false, - "applicationUrl": "https://localhost:7129;http://localhost:5030", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - } - } -} diff --git a/samples/Sample.AspNetCore.Orgs/Sample.AspNetCore.Orgs.csproj b/samples/Sample.AspNetCore.Orgs/Sample.AspNetCore.Orgs.csproj deleted file mode 100644 index 6342763..0000000 --- a/samples/Sample.AspNetCore.Orgs/Sample.AspNetCore.Orgs.csproj +++ /dev/null @@ -1,18 +0,0 @@ - - - - net9.0 - enable - enable - - - - - - - - - - - - diff --git a/samples/Sample.AspNetCore.Orgs/Sample.AspNetCore.Orgs.http b/samples/Sample.AspNetCore.Orgs/Sample.AspNetCore.Orgs.http deleted file mode 100644 index 47d6e9b..0000000 --- a/samples/Sample.AspNetCore.Orgs/Sample.AspNetCore.Orgs.http +++ /dev/null @@ -1,6 +0,0 @@ -@Sample.AspNetCore.Orgs_HostAddress = http://localhost:5030 - -GET {{Sample.AspNetCore.Orgs_HostAddress}}/weatherforecast/ -Accept: application/json - -### diff --git a/samples/Sample.AspNetCore.Orgs/appsettings.Development.json b/samples/Sample.AspNetCore.Orgs/appsettings.Development.json deleted file mode 100644 index 0c208ae..0000000 --- a/samples/Sample.AspNetCore.Orgs/appsettings.Development.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - } -} diff --git a/samples/Sample.AspNetCore.Orgs/appsettings.json b/samples/Sample.AspNetCore.Orgs/appsettings.json deleted file mode 100644 index 10f68b8..0000000 --- a/samples/Sample.AspNetCore.Orgs/appsettings.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "AllowedHosts": "*" -} diff --git a/samples/Sample.AspNetCore/Program.cs b/samples/Sample.AspNetCore/Program.cs index d4928b7..88c5e4d 100644 --- a/samples/Sample.AspNetCore/Program.cs +++ b/samples/Sample.AspNetCore/Program.cs @@ -10,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"]; }); @@ -36,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"]; }); @@ -53,9 +52,12 @@ app.MapGrpcService(); -app.MapGet("/users", async ([FromServices] IManagementApiClient client, HttpContext context) => +app.MapGet("/users", 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(); diff --git a/samples/Sample.ConsoleApp/Program.cs b/samples/Sample.ConsoleApp/Program.cs index 1b02cf5..975bda8 100644 --- a/samples/Sample.ConsoleApp/Program.cs +++ b/samples/Sample.ConsoleApp/Program.cs @@ -27,10 +27,6 @@ .AddAccessToken(config => { config.Audience = builder.Configuration["AspNetCore:Audience"]; - config.OrganizationResolver = x => - x.Headers.TryGetValues("org-id", out var values) - ? values.SingleOrDefault() - : null; }); diff --git a/src/Auth0Net.DependencyInjection/Cache/Auth0TokenCache.cs b/src/Auth0Net.DependencyInjection/Cache/Auth0TokenCache.cs index ef34258..bb40ca3 100644 --- a/src/Auth0Net.DependencyInjection/Cache/Auth0TokenCache.cs +++ b/src/Auth0Net.DependencyInjection/Cache/Auth0TokenCache.cs @@ -33,10 +33,7 @@ public Auth0TokenCache(IAuthenticationApiClient client, IFusionCacheProvider pro _logger = logger; _config = config.Value; } - - /// - public ValueTask GetTokenAsync(string audience, CancellationToken token = default) => GetTokenAsync(audience, null, token); - + /// public async ValueTask GetTokenAsync(string audience, string? organization = null, CancellationToken token = default) { @@ -72,6 +69,9 @@ public async ValueTask GetTokenAsync(string audience, string? organizati 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/HttpClient/Auth0ManagementTokenHandler.cs b/src/Auth0Net.DependencyInjection/HttpClient/Auth0ManagementTokenHandler.cs deleted file mode 100644 index f548e82..0000000 --- a/src/Auth0Net.DependencyInjection/HttpClient/Auth0ManagementTokenHandler.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System.Net.Http; -using System.Net.Http.Headers; -using Auth0Net.DependencyInjection.Cache; -using Microsoft.Extensions.Options; - -namespace Auth0Net.DependencyInjection.HttpClient; - -internal sealed class Auth0ManagementTokenHandler : DelegatingHandler -{ - private const string Scheme = "Bearer"; - private readonly IAuth0TokenCache _cache; - private readonly IOptions _auth0Configuration; - private readonly Auth0ManagementTokenConfiguration _managementConfiguration; - - public Auth0ManagementTokenHandler(IAuth0TokenCache cache, IOptions auth0Configuration, Auth0ManagementTokenConfiguration managementConfiguration) - { - _cache = cache; - _auth0Configuration = auth0Configuration; - _managementConfiguration = managementConfiguration; - } - - /// - protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - var token = await _cache.GetTokenAsync(_managementConfiguration.Audience ?? _auth0Configuration.Value.Domain, cancellationToken); - - request.Headers.Authorization = new AuthenticationHeaderValue(Scheme, token); - - return await base.SendAsync(request, cancellationToken); - } -} \ No newline at end of file diff --git a/tests/Auth0Net.DependencyInjection.Tests/CacheTests.cs b/tests/Auth0Net.DependencyInjection.Tests/CacheTests.cs index 410bc88..5623499 100644 --- a/tests/Auth0Net.DependencyInjection.Tests/CacheTests.cs +++ b/tests/Auth0Net.DependencyInjection.Tests/CacheTests.cs @@ -63,8 +63,10 @@ public async Task Cache_WorksAsExpected() [Fact] - public async Task Cache_WhenGivenOrgId_ReturnsOrgId() + public async Task Cache_WhenGivenOrgId_PassesOrgIdToTokenRequest() { + const string orgId = "org_123456"; + var config = A.Fake>(); A.CallTo(() => config.Value).Returns(new Auth0Configuration { @@ -74,39 +76,41 @@ public async Task Cache_WhenGivenOrgId_ReturnsOrgId() }); var authClient = A.Fake(); + A.CallTo(() => authClient.GetTokenAsync(A.Ignored, A.Ignored)) + .Returns(new AccessTokenResponse { AccessToken = "token", ExpiresIn = 60 }); - var accessTokenFirst = Guid.NewGuid().ToString(); - - A.CallTo(() => authClient.GetTokenAsync(A.Ignored, A.Ignored)).Returns( - new AccessTokenResponse - { - AccessToken = accessTokenFirst, - ExpiresIn = 1 - }); - - var cache = new Auth0TokenCache(authClient, new FusionCacheTestProvider(), new NullLogger(), config); - var key = "api://my-audience"; - var resFirst = await cache.GetTokenAsync(key, TestContext.Current.CancellationToken); - Assert.Equal(accessTokenFirst, resFirst); - await Task.Delay(1000, TestContext.Current.CancellationToken); + await cache.GetTokenAsync("api://my-audience", orgId, TestContext.Current.CancellationToken); + A.CallTo(() => authClient.GetTokenAsync( + A.That.Matches(r => r.Organization == orgId), + A.Ignored)) + .MustHaveHappenedOnceExactly(); + } - var accessTokenSecond = Guid.NewGuid().ToString(); + [Fact] + public async Task Cache_UsesFusionCacheInstance_WhenConfigured() + { + const string customCacheName = "my-custom-cache"; - A.CallTo(() => authClient.GetTokenAsync(A.Ignored, A.Ignored)).Returns( - new AccessTokenResponse - { - AccessToken = accessTokenSecond, - ExpiresIn = 1 - }); - - var resSecond = await cache.GetTokenAsync(key, TestContext.Current.CancellationToken); - Assert.Equal(accessTokenSecond, resSecond); + 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/", + FusionCacheInstance = customCacheName + }); + var authClient = A.Fake(); A.CallTo(() => authClient.GetTokenAsync(A.Ignored, A.Ignored)) - .MustHaveHappenedTwiceExactly(); + .Returns(new AccessTokenResponse { AccessToken = "token", ExpiresIn = 60 }); + + var provider = new CapturingFusionCacheProvider(); + _ = new Auth0TokenCache(authClient, provider, new NullLogger(), config); + + Assert.Equal(customCacheName, provider.LastRequestedCacheName); } [Fact] diff --git a/tests/Auth0Net.DependencyInjection.Tests/OrgTests.cs b/tests/Auth0Net.DependencyInjection.Tests/OrgTests.cs deleted file mode 100644 index e4cc141..0000000 --- a/tests/Auth0Net.DependencyInjection.Tests/OrgTests.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Auth0Net.DependencyInjection.Tests; - -public class OrgTests -{ - -} \ 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..022cf1e --- /dev/null +++ b/tests/Auth0Net.DependencyInjection.Tests/OrganizationAccessorTests.cs @@ -0,0 +1,83 @@ +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(); + } + + 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..2f0b3c4 --- /dev/null +++ b/tests/Auth0Net.DependencyInjection.Tests/OrganizationScopeFactoryTests.cs @@ -0,0 +1,55 @@ +using System; +using Auth0.AuthenticationApi; +using Auth0.ManagementApi; +using Auth0Net.DependencyInjection.Organizations; +using FakeItEasy; +using Xunit; + +namespace Auth0Net.DependencyInjection.Tests; + +public class OrganizationScopeFactoryTests +{ + [Fact] + public void OrganizationScopeFactory_ThrowsForAuthenticationApiClient() + { + var client = A.Fake(); + var accessor = new HttpClientOrganizationAccessor(); + +#pragma warning disable AUTH0_EXPERIMENTAL + Assert.Throws(() => + new OrganizationScopeFactory(client, accessor)); +#pragma warning restore AUTH0_EXPERIMENTAL + } + + [Fact] + public void OrganizationScopeFactory_ThrowsForManagementApiClient() + { + var client = A.Fake(); + var accessor = new HttpClientOrganizationAccessor(); + +#pragma warning disable AUTH0_EXPERIMENTAL + Assert.Throws(() => + new OrganizationScopeFactory(client, accessor)); +#pragma warning restore AUTH0_EXPERIMENTAL + } + + [Fact] + public void OrganizationScopeFactory_CreateScope_SetsOrganizationOnAccessorAndExposesClient() + { + var client = new TestClient(); + var accessor = new HttpClientOrganizationAccessor(); + +#pragma warning disable AUTH0_EXPERIMENTAL + var factory = new OrganizationScopeFactory(client, accessor); + var scope = factory.CreateScope("org-123"); +#pragma warning restore AUTH0_EXPERIMENTAL + + Assert.Equal("org-123", accessor.Organization); + Assert.Same(client, scope.Client); + + scope.Dispose(); + Assert.Null(accessor.Organization); + } + + private sealed class TestClient { } +} diff --git a/tests/Auth0Net.DependencyInjection.Tests/OrganizationScopeHandlerTests.cs b/tests/Auth0Net.DependencyInjection.Tests/OrganizationScopeHandlerTests.cs deleted file mode 100644 index 02d79c8..0000000 --- a/tests/Auth0Net.DependencyInjection.Tests/OrganizationScopeHandlerTests.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.Threading.Tasks; -using Xunit; - -namespace Auth0Net.DependencyInjection.Tests; - -public class OrganizationScopeHandlerTests -{ - [Fact] - public async Task OrganizationScope_AppliesOrganizationToTokenHandler() - { - - } - -} - \ No newline at end of file From 6f0b204aab1f1180491cb482e02e136b17029478 Mon Sep 17 00:00:00 2001 From: JT Date: Thu, 2 Apr 2026 20:31:35 +0800 Subject: [PATCH 05/13] Remove .NET 8 restriction --- .../HttpClient/Auth0TokenHandler.cs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/Auth0Net.DependencyInjection/HttpClient/Auth0TokenHandler.cs b/src/Auth0Net.DependencyInjection/HttpClient/Auth0TokenHandler.cs index 32a5ae9..60fd273 100644 --- a/src/Auth0Net.DependencyInjection/HttpClient/Auth0TokenHandler.cs +++ b/src/Auth0Net.DependencyInjection/HttpClient/Auth0TokenHandler.cs @@ -33,12 +33,8 @@ protected override async Task SendAsync(HttpRequestMessage { var audience = _handlerConfig.Audience ?? _handlerConfig.AudienceResolver?.Invoke(request) ?? throw new ArgumentException("Audience cannot be computed"); - #if NET8_0_OR_GREATER var org = _accessor.Organization ?? _handlerConfig.Organization ?? _handlerConfig.AudienceResolver?.Invoke(request); var token = await _cache.GetTokenAsync(audience, org, cancellationToken); - #else - var token = await _cache.GetTokenAsync(audience, cancellationToken); - #endif request.Headers.Authorization = new AuthenticationHeaderValue(Scheme, token); From 5cc7af5b480d94e9aa6112330dc80e84307160af Mon Sep 17 00:00:00 2001 From: JT Date: Thu, 2 Apr 2026 22:15:23 +0800 Subject: [PATCH 06/13] Update docs --- README.md | 76 +++++++++++-------- samples/Sample.AspNetCore/Program.cs | 7 ++ .../Cache/Auth0TokenCache.cs | 7 +- .../HttpClientOrganizationAccessor.cs | 7 ++ .../Organizations/OrganizationScope.cs | 22 +++++- .../Organizations/OrganizationScopeFactory.cs | 28 ++++--- .../CacheTests.cs | 2 +- 7 files changed, 102 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index db60056..d9b820b 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. @@ -93,14 +93,14 @@ 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"; }); ``` @@ -109,7 +109,7 @@ services.AddAuth0ManagementClient() ![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,12 +140,13 @@ services.AddHttpClient(x=> x.BaseAddress = new Uri("https://MySer .AddAccessToken(config => config.AudienceResolver = request => request.RequestUri.GetLeftPart(UriPartial.Authority)); ``` - ### 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. +Your Auth0 org must have this functionality configured before + #### 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: @@ -162,7 +163,7 @@ builder.Services #### Dynamic Organization via Request Metadata -If you already include org metadata as part of your network request or via the request options, you can choose to resolve the organization at runtime via the `OrganizationResolver`: +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 builder.Services @@ -179,33 +180,37 @@ builder.Services #### 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 likely want the ability to freely set the Organization. +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`: -There's a few caveats if you're using this functionality, as it utilizes an `AsyncLocal` internally: - -- Never use multiple client scopes at the same time, either with the same or different client types. -- Never call any other client that utilizes `.AddAccessToken` within a client scope. +```csharp +// Inject the factory around your remote client +private readonly OrganizationScopeFactory _scopeFactory; -Doing any of the above is likely to result in the wrong Organization ID/Name being used for a given request. +public QueryUsersService(OrganizationScopeFactory scopeFactory) +{ + _scopeFactory = scopeFactory; +} -## Additional Functionality +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); + var userHttpClient = await orgScope.Client.CreateUser(user, stoppingToken); +} + +``` -### Enhanced Resilience +There's a few limitations if you're using this functionality, as it uses `AsyncLocal` internally: -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: +- Never use multiple client scopes at the same time, either with the same or different client types. +- Never call any other client that utilizes `.AddAccessToken` within a client scope. -```csharp -services.AddAuth0ManagementClient() - .AddManagementAccessToken() - .AddAuth0RateLimitResilience(); -``` +Doing any of the above is likely to result in hard to debug behaviour and may cause the wrong Organization ID/Name being used for a given request. -When a retry occurs, you should see a warning log similar to: +This functionality is marked as experimental, and you must `#pragma warning disable AUTH0_EXPERIMENTAL` to use it. -`Resilience event occurred. EventName: '"OnRetry"', Source: '"IManagementConnection-RateLimitRetry"/""/"Retry"', Operation Key: 'null', Result: '429'` +## Additional Functionality ### Utility @@ -242,7 +247,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 88c5e4d..6d1988b 100644 --- a/samples/Sample.AspNetCore/Program.cs +++ b/samples/Sample.AspNetCore/Program.cs @@ -53,6 +53,13 @@ app.MapGrpcService(); app.MapGet("/users", async ([FromServices] IManagementApiClient client, HttpContext context, ILogger logger) => +{ + 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.MapGet("/users/org-scoped", async ([FromServices] IManagementApiClient client, HttpContext context, ILogger logger) => { var orgId = context.User.FindFirstValue("org_id"); if (!string.IsNullOrEmpty(orgId)) { diff --git a/src/Auth0Net.DependencyInjection/Cache/Auth0TokenCache.cs b/src/Auth0Net.DependencyInjection/Cache/Auth0TokenCache.cs index bb40ca3..48ab51f 100644 --- a/src/Auth0Net.DependencyInjection/Cache/Auth0TokenCache.cs +++ b/src/Auth0Net.DependencyInjection/Cache/Auth0TokenCache.cs @@ -26,10 +26,11 @@ 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; } diff --git a/src/Auth0Net.DependencyInjection/Organizations/HttpClientOrganizationAccessor.cs b/src/Auth0Net.DependencyInjection/Organizations/HttpClientOrganizationAccessor.cs index 7a9e200..8056d1b 100644 --- a/src/Auth0Net.DependencyInjection/Organizations/HttpClientOrganizationAccessor.cs +++ b/src/Auth0Net.DependencyInjection/Organizations/HttpClientOrganizationAccessor.cs @@ -1,9 +1,16 @@ 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; diff --git a/src/Auth0Net.DependencyInjection/Organizations/OrganizationScope.cs b/src/Auth0Net.DependencyInjection/Organizations/OrganizationScope.cs index 53708fc..b57a36a 100644 --- a/src/Auth0Net.DependencyInjection/Organizations/OrganizationScope.cs +++ b/src/Auth0Net.DependencyInjection/Organizations/OrganizationScope.cs @@ -1,16 +1,30 @@ namespace Auth0Net.DependencyInjection.Organizations; -public class OrganizationScope : IDisposable where T: class +/// +/// 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; - public OrganizationScope(T client, 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; diff --git a/src/Auth0Net.DependencyInjection/Organizations/OrganizationScopeFactory.cs b/src/Auth0Net.DependencyInjection/Organizations/OrganizationScopeFactory.cs index 5fdda14..39328b9 100644 --- a/src/Auth0Net.DependencyInjection/Organizations/OrganizationScopeFactory.cs +++ b/src/Auth0Net.DependencyInjection/Organizations/OrganizationScopeFactory.cs @@ -5,9 +5,12 @@ 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 { @@ -15,11 +18,13 @@ public class OrganizationScopeFactory where TClient: class 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) @@ -32,10 +37,15 @@ public OrganizationScopeFactory(TClient client, HttpClientOrganizationAccessor a } /// - /// + /// 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) { _accessor.Organization = organization; diff --git a/tests/Auth0Net.DependencyInjection.Tests/CacheTests.cs b/tests/Auth0Net.DependencyInjection.Tests/CacheTests.cs index 5623499..4c137ec 100644 --- a/tests/Auth0Net.DependencyInjection.Tests/CacheTests.cs +++ b/tests/Auth0Net.DependencyInjection.Tests/CacheTests.cs @@ -147,7 +147,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) { From 152dc40b69a29c28b92dcc371eb7240aec63e7ca Mon Sep 17 00:00:00 2001 From: JT Date: Thu, 2 Apr 2026 22:18:17 +0800 Subject: [PATCH 07/13] Test fix --- tests/Auth0Net.DependencyInjection.Tests/CacheTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/Auth0Net.DependencyInjection.Tests/CacheTests.cs b/tests/Auth0Net.DependencyInjection.Tests/CacheTests.cs index 4c137ec..79a5c4e 100644 --- a/tests/Auth0Net.DependencyInjection.Tests/CacheTests.cs +++ b/tests/Auth0Net.DependencyInjection.Tests/CacheTests.cs @@ -157,7 +157,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 From aa3117d64e1b1ff24cf33aa05b1e89423384aec5 Mon Sep 17 00:00:00 2001 From: JT Date: Thu, 2 Apr 2026 22:25:25 +0800 Subject: [PATCH 08/13] nit --- samples/Sample.AspNetCore/Program.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/samples/Sample.AspNetCore/Program.cs b/samples/Sample.AspNetCore/Program.cs index 6d1988b..5f33c0e 100644 --- a/samples/Sample.AspNetCore/Program.cs +++ b/samples/Sample.AspNetCore/Program.cs @@ -52,7 +52,7 @@ app.MapGrpcService(); -app.MapGet("/users", async ([FromServices] IManagementApiClient client, HttpContext context, ILogger logger) => +app.MapGet("/users", async ([FromServices] IManagementApiClient client) => { var user = await client.Users.ListAsync(new ListUsersRequestParameters() { }); From 88c99cbd895130df9b8acb3def16992e7a0a15d9 Mon Sep 17 00:00:00 2001 From: JT Date: Thu, 2 Apr 2026 22:34:54 +0800 Subject: [PATCH 09/13] Add tests for org config --- .../HttpClient/Auth0TokenHandler.cs | 2 +- .../OrganizationAccessorTests.cs | 130 ++++++++++++++++++ 2 files changed, 131 insertions(+), 1 deletion(-) diff --git a/src/Auth0Net.DependencyInjection/HttpClient/Auth0TokenHandler.cs b/src/Auth0Net.DependencyInjection/HttpClient/Auth0TokenHandler.cs index 60fd273..73c0c84 100644 --- a/src/Auth0Net.DependencyInjection/HttpClient/Auth0TokenHandler.cs +++ b/src/Auth0Net.DependencyInjection/HttpClient/Auth0TokenHandler.cs @@ -33,7 +33,7 @@ protected override async Task SendAsync(HttpRequestMessage { var audience = _handlerConfig.Audience ?? _handlerConfig.AudienceResolver?.Invoke(request) ?? throw new ArgumentException("Audience cannot be computed"); - var org = _accessor.Organization ?? _handlerConfig.Organization ?? _handlerConfig.AudienceResolver?.Invoke(request); + 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); diff --git a/tests/Auth0Net.DependencyInjection.Tests/OrganizationAccessorTests.cs b/tests/Auth0Net.DependencyInjection.Tests/OrganizationAccessorTests.cs index 022cf1e..2e3c4de 100644 --- a/tests/Auth0Net.DependencyInjection.Tests/OrganizationAccessorTests.cs +++ b/tests/Auth0Net.DependencyInjection.Tests/OrganizationAccessorTests.cs @@ -63,6 +63,136 @@ public async Task TokenHandler_AccessorOrganizationTakesPrecedenceOverConfig() .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, From a411eb0cb048c19805c136bb9684579ccbebb924 Mon Sep 17 00:00:00 2001 From: JT Date: Thu, 2 Apr 2026 22:53:02 +0800 Subject: [PATCH 10/13] Guard against scope nesting --- README.md | 8 +++--- .../Organizations/OrganizationScopeFactory.cs | 7 +++++ .../OrganizationScopeFactoryTests.cs | 27 +++++++++++++------ 3 files changed, 31 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index d9b820b..00e41ea 100644 --- a/README.md +++ b/README.md @@ -201,12 +201,14 @@ public async Task CreateUserAsync(User user, string orgId) ``` +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. -- Never call any other client that utilizes `.AddAccessToken` within a client scope. +- 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. -Doing any of the above is likely to result in hard to debug behaviour and 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. diff --git a/src/Auth0Net.DependencyInjection/Organizations/OrganizationScopeFactory.cs b/src/Auth0Net.DependencyInjection/Organizations/OrganizationScopeFactory.cs index 39328b9..2179de7 100644 --- a/src/Auth0Net.DependencyInjection/Organizations/OrganizationScopeFactory.cs +++ b/src/Auth0Net.DependencyInjection/Organizations/OrganizationScopeFactory.cs @@ -48,6 +48,13 @@ public OrganizationScopeFactory(TClient client, HttpClientOrganizationAccessor a /// 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); } diff --git a/tests/Auth0Net.DependencyInjection.Tests/OrganizationScopeFactoryTests.cs b/tests/Auth0Net.DependencyInjection.Tests/OrganizationScopeFactoryTests.cs index 2f0b3c4..996e83e 100644 --- a/tests/Auth0Net.DependencyInjection.Tests/OrganizationScopeFactoryTests.cs +++ b/tests/Auth0Net.DependencyInjection.Tests/OrganizationScopeFactoryTests.cs @@ -6,7 +6,7 @@ using Xunit; namespace Auth0Net.DependencyInjection.Tests; - +#pragma warning disable AUTH0_EXPERIMENTAL public class OrganizationScopeFactoryTests { [Fact] @@ -15,10 +15,9 @@ public void OrganizationScopeFactory_ThrowsForAuthenticationApiClient() var client = A.Fake(); var accessor = new HttpClientOrganizationAccessor(); -#pragma warning disable AUTH0_EXPERIMENTAL Assert.Throws(() => new OrganizationScopeFactory(client, accessor)); -#pragma warning restore AUTH0_EXPERIMENTAL + } [Fact] @@ -27,10 +26,22 @@ public void OrganizationScopeFactory_ThrowsForManagementApiClient() var client = A.Fake(); var accessor = new HttpClientOrganizationAccessor(); -#pragma warning disable AUTH0_EXPERIMENTAL Assert.Throws(() => new OrganizationScopeFactory(client, accessor)); -#pragma warning restore AUTH0_EXPERIMENTAL + } + + [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] @@ -39,11 +50,9 @@ public void OrganizationScopeFactory_CreateScope_SetsOrganizationOnAccessorAndEx var client = new TestClient(); var accessor = new HttpClientOrganizationAccessor(); -#pragma warning disable AUTH0_EXPERIMENTAL var factory = new OrganizationScopeFactory(client, accessor); var scope = factory.CreateScope("org-123"); -#pragma warning restore AUTH0_EXPERIMENTAL - + Assert.Equal("org-123", accessor.Organization); Assert.Same(client, scope.Client); @@ -51,5 +60,7 @@ public void OrganizationScopeFactory_CreateScope_SetsOrganizationOnAccessorAndEx Assert.Null(accessor.Organization); } + + private sealed class TestClient { } } From 7f9dc44d548f3667fced2ed61836f4547104e530 Mon Sep 17 00:00:00 2001 From: JT Date: Thu, 2 Apr 2026 23:01:05 +0800 Subject: [PATCH 11/13] More doc fixes --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 00e41ea..6c37d05 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ services.AddHttpClient(x=> x.BaseAddress = new Uri("https://MySer 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. -Your Auth0 org must have this functionality configured before +Orgs support must be enabled for your combination of client/api/org(s) before usage. #### Static Organization From a8215671447b0e1020db7a2c3253034e361cf000 Mon Sep 17 00:00:00 2001 From: JT Date: Thu, 2 Apr 2026 23:09:52 +0800 Subject: [PATCH 12/13] Include the org in the cache key. --- README.md | 2 +- .../Cache/Auth0TokenCache.cs | 4 +-- .../CacheTests.cs | 34 +++++++++++++++++++ 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 6c37d05..9e7d34f 100644 --- a/README.md +++ b/README.md @@ -196,7 +196,7 @@ 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); - var userHttpClient = await orgScope.Client.CreateUser(user, stoppingToken); + await orgScope.Client.CreateUser(user, stoppingToken); } ``` diff --git a/src/Auth0Net.DependencyInjection/Cache/Auth0TokenCache.cs b/src/Auth0Net.DependencyInjection/Cache/Auth0TokenCache.cs index 48ab51f..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 @@ -40,7 +40,7 @@ public async ValueTask GetTokenAsync(string audience, string? organizati { _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); diff --git a/tests/Auth0Net.DependencyInjection.Tests/CacheTests.cs b/tests/Auth0Net.DependencyInjection.Tests/CacheTests.cs index 79a5c4e..4ed63a3 100644 --- a/tests/Auth0Net.DependencyInjection.Tests/CacheTests.cs +++ b/tests/Auth0Net.DependencyInjection.Tests/CacheTests.cs @@ -89,6 +89,40 @@ public async Task Cache_WhenGivenOrgId_PassesOrgIdToTokenRequest() .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() { From 25d89f200b7c4e9b18aef8cdb6c4e75c58e2981b Mon Sep 17 00:00:00 2001 From: JT Date: Fri, 3 Apr 2026 13:22:59 +0800 Subject: [PATCH 13/13] Doc tweaks --- README.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 9e7d34f..2a0f1a0 100644 --- a/README.md +++ b/README.md @@ -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; @@ -218,7 +217,7 @@ This functionality is marked as experimental, and you must `#pragma warning disa 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: @@ -239,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