20

We have multiple tenants, and they use different authorities (their own, not just standard providers). While I know how to dynamically set the clientId and secret, I can't figure out how to set the authority. It is set once, during startup, and afterwards it cannot be changed (or so it seems).

Since we have a lot of tenants we don't want to register all at startup, and we also don't want to require a restart when tenants are added.

Any suggestions how I can go about this? I'd love to use the existing middleware, but if it's not possible I could write my own.

Appreciate any suggestion!

Iris Classon
  • 5,752
  • 3
  • 33
  • 52
  • Check out using a Federation Gateway with IdentityServer4 http://docs.identityserver.io/en/release/topics/federation_gateway.html. This will allow a single authority (IdentityServer4) that can handle external authentication or multi-tenant requirements. – Brad Oct 24 '18 at 01:43

2 Answers2

24

While a bit tricky, it's definitely possible. Here's a simplified example, using the MSFT OIDC handler, a custom monitor and path-based tenant resolution:

Implement your tenant resolution logic. E.g:

public class TenantProvider
{
    private readonly IHttpContextAccessor _httpContextAccessor;

    public TenantProvider(IHttpContextAccessor httpContextAccessor)
        => _httpContextAccessor = httpContextAccessor;

    public string GetCurrentTenant()
    {
        // This sample uses the path base as the tenant.
        // You can replace that by your own logic.
        string tenant = _httpContextAccessor.HttpContext.Request.PathBase;
        if (string.IsNullOrEmpty(tenant))
        {
            tenant = "default";
        }

        return tenant;
    }
}
public void Configure(IApplicationBuilder app)
{
    app.Use(next => context =>
    {
        // This snippet uses a hardcoded resolution logic.
        // In a real world app, you'd want to customize that.
        if (context.Request.Path.StartsWithSegments("/fabrikam", out PathString path))
        {
            context.Request.PathBase = "/fabrikam";
            context.Request.Path = path;
        }

        return next(context);
    });

    app.UseAuthentication();

    app.UseMvc();
}

Implement a custom IOptionsMonitor<OpenIdConnectOptions>:

public class OpenIdConnectOptionsProvider : IOptionsMonitor<OpenIdConnectOptions>
{
    private readonly ConcurrentDictionary<(string name, string tenant), Lazy<OpenIdConnectOptions>> _cache;
    private readonly IOptionsFactory<OpenIdConnectOptions> _optionsFactory;
    private readonly TenantProvider _tenantProvider;

    public OpenIdConnectOptionsProvider(
        IOptionsFactory<OpenIdConnectOptions> optionsFactory,
        TenantProvider tenantProvider)
    {
        _cache = new ConcurrentDictionary<(string, string), Lazy<OpenIdConnectOptions>>();
        _optionsFactory = optionsFactory;
        _tenantProvider = tenantProvider;
    }

    public OpenIdConnectOptions CurrentValue => Get(Options.DefaultName);

    public OpenIdConnectOptions Get(string name)
    {
        var tenant = _tenantProvider.GetCurrentTenant();

        Lazy<OpenIdConnectOptions> Create() => new Lazy<OpenIdConnectOptions>(() => _optionsFactory.Create(name));
        return _cache.GetOrAdd((name, tenant), _ => Create()).Value;
    }

    public IDisposable OnChange(Action<OpenIdConnectOptions, string> listener) => null;
}

Implement a custom IConfigureNamedOptions<OpenIdConnectOptions>:

public class OpenIdConnectOptionsInitializer : IConfigureNamedOptions<OpenIdConnectOptions>
{
    private readonly IDataProtectionProvider _dataProtectionProvider;
    private readonly TenantProvider _tenantProvider;

    public OpenIdConnectOptionsInitializer(
        IDataProtectionProvider dataProtectionProvider,
        TenantProvider tenantProvider)
    {
        _dataProtectionProvider = dataProtectionProvider;
        _tenantProvider = tenantProvider;
    }

    public void Configure(string name, OpenIdConnectOptions options)
    {
        if (!string.Equals(name, OpenIdConnectDefaults.AuthenticationScheme, StringComparison.Ordinal))
        {
            return;
        }

        var tenant = _tenantProvider.GetCurrentTenant();

        // Create a tenant-specific data protection provider to ensure
        // encrypted states can't be read/decrypted by the other tenants.
        options.DataProtectionProvider = _dataProtectionProvider.CreateProtector(tenant);

        // Other tenant-specific options like options.Authority can be registered here.
    }

    public void Configure(OpenIdConnectOptions options)
        => Debug.Fail("This infrastructure method shouldn't be called.");
}

Register the services in your DI container:

public void ConfigureServices(IServiceCollection services)
{
    // ...

    // Register the OpenID Connect handler.
    services.AddAuthentication()
        .AddOpenIdConnect();

    services.AddSingleton<TenantProvider>();
    services.AddSingleton<IOptionsMonitor<OpenIdConnectOptions>, OpenIdConnectOptionsProvider>();
    services.AddSingleton<IConfigureOptions<OpenIdConnectOptions>, OpenIdConnectOptionsInitializer>();
}
Kévin Chalet
  • 39,509
  • 7
  • 121
  • 131
  • Sorry for the late reply, took some time to get the other parts working, but I think I have a working example based on your reply. I'll post the finished version as soon as I'm done. Thank you! – Iris Classon Nov 01 '18 at 17:19
  • This is awesome. The only suggestion I would add is to use app.UsePathBase(new PathString("/fabrikam")). This lets you use then normal routing in your controllers as it ignores this prefix. – ravi May 22 '20 at 00:19
  • 1
    Thank you for the details sample Kevin. I have made it works for JwtBearer based on your approach. Do you have any other ideas about implementation after 2 years? I was not able to come up with any better solution for ASP.NET Core 3.1. What do you think? – SerjG Oct 28 '20 at 12:25
  • I am trying to do something similar, my tenant is just read from a user session, and can therefore change at runtime for the individual user. I set the tenant id as part of the `scope` list, but i can't really figure out how to request a new token with a different config at runtime? – aweis Jun 04 '22 at 16:49
  • 1
    I would like to second @SerjG comment - as of Asp.Net 6.0, do we know if this is still the best method to obtain multi-tenant JwtBearer Authentication in .Net 6.0? – Brian Mikinski Oct 31 '22 at 16:08
  • In .NET 7 this doesn't work anymore, in `OpenIdConnectOptionsProvider`, the `_optionsFactory.Create(name)` call will throw a validation exception by calling the `OpenIdConnectOptions.Validate()` method since it's lacking a valid `CliendId` parameter https://github.com/dotnet/aspnetcore/blob/15e9bd26449b17ec0677f4e353b74507832f4bad/src/Security/Authentication/OpenIdConnect/src/OpenIdConnectOptions.cs#L81 – Alex Jan 11 '23 at 15:02
  • @Alex it's not new: your custom options initializer must populate all the required options, `ClientId` included. .NET 7 hasn't changed anything in this regard. – Kévin Chalet Jan 11 '23 at 19:14
  • @KévinChalet I put a breakpoint in the `OpenIdConnectOptionsProvider.Get(string? name)` method as well as in the `OpenIdConnectOptionsInitializer.Configure(string? name, OpenIdConnectOptions options)` methods, but only the first one is hit when I refresh the page, so my `OpenIdConnectOptionsInitializer` class never gets called it seems. I guess there must be some kind of link between this class and the `IOptionsFactory` instance which gets injected in `OpenIdConnectOptionsProvider`, but I struggle to good documentation about this interface... – Alex Jan 23 '23 at 10:31
  • @KévinChalet I found my problem, I registered in DI as follows: `services.AddSingleton, OpenIdConnectOptionsInitializer>();` Instead of `services.AddSingleton, OpenIdConnectOptionsInitializer>();`. Counter intuitively it doesn't seem to work when you add the specific `IConfigureNamedOptions` interface to the DI container. It was correct in your answer, but you might want to point out in a comment or such that you have to register another interface in the DI container as the specific one you implemented – Alex Jan 23 '23 at 17:50
1

The Asp.NET Core model assumes one upstream authority per handler instance. My Saml2 component supports multiple upstream Idps in one handler and it has drawbacks in the rest of the system when that assumption no longer is true.

In Asp.NET Core it is possible to add/remove providers at runtime, without requiring a restart. So I'd recommend finding a model based on that.

If you rather want one handler that can have a per-request Authority setting, I think that a custom handler is needed - Microsoft's default implementation won't support that.

Anders Abel
  • 67,989
  • 17
  • 150
  • 217
  • 1
    Thank you for the input Anders, you are always very helpful! I guess I was spoiled after implementing SAML support with your component, I just assumed it would be just as easy :D I got it working with the suggestion from @Pinpoint – Iris Classon Nov 01 '18 at 17:17