14

I have a multi-tenant (single database) application which allows for same username/email across different tenants.

At the time of login (Implicit flow) how can I identify the tenant? I thought of following possibilities:

  1. At the time of registration ask the user for account slug (company/tenant slug) and during login user should provide the slug along with username and password.

    But there is no parameter in open id request to send the slug.

  2. Create an OAuth application at the time of registration and use slug as client_id. At the time of login pass slug in client_id, which I will use to fetch the tenant Id and proceed further to validate the user.

Is this approach fine?

Edit:

Also tried making slug part of route param

.EnableTokenEndpoint("/connect/{slug}/token");

but openiddict doesn't support that.

Community
  • 1
  • 1
adnan kamili
  • 8,967
  • 7
  • 65
  • 125

3 Answers3

27

Edit: this answer was updated to use OpenIddict 4.x.


The approach suggested by McGuire will work with OpenIddict (you can access the acr_values property via OpenIddictRequest.AcrValues) but it's not the recommended option (it's not ideal from a security perspective: since the issuer is the same for all the tenants, they end up sharing the same signing keys).

Instead, consider running an issuer per tenant. For that, you have at least 2 options:

  • Give OrchardCore's OpenID module a try: it's based on OpenIddict and natively supports multi-tenancy. It's still in beta but it's actively developed.

  • Override the options monitor used by OpenIddict to use per-tenant options.

Here's a simplified example of the second option, using 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.UseDeveloperExceptionPage();

    app.UseStaticFiles();

    app.UseStatusCodePagesWithReExecute("/error");

    app.UseRouting();

    app.UseAuthentication();
    app.UseAuthorization();

    app.UseEndpoints(options =>
    {
        options.MapControllers();
        options.MapDefaultControllerRoute();
    });
}

Implement a custom IOptionsMonitor<OpenIddictServerOptions>:

public class OpenIddictServerOptionsProvider : IOptionsMonitor<OpenIddictServerOptions>
{
    private readonly ConcurrentDictionary<(string Name, string Tenant), Lazy<OpenIddictServerOptions>> _cache;
    private readonly IOptionsFactory<OpenIddictServerOptions> _optionsFactory;
    private readonly TenantProvider _tenantProvider;

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

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

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

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

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

Implement a custom IConfigureNamedOptions<OpenIddictServerOptions>:

public class OpenIddictServerOptionsInitializer : IConfigureNamedOptions<OpenIddictServerOptions>
{
    private readonly TenantProvider _tenantProvider;

    public OpenIddictServerOptionsInitializer(TenantProvider tenantProvider)
        => _tenantProvider = tenantProvider;

    public void Configure(string name, OpenIddictServerOptions options) => Configure(options);

    public void Configure(OpenIddictServerOptions options)
    {
        var tenant = _tenantProvider.GetCurrentTenant();

        // Resolve the signing credentials associated with the tenant (in a real world application,
        // the credentials would be retrieved from a persistent storage like a database or a key vault).
        options.SigningCredentials.Add(tenant switch
        {
            "fabrikam" => new(new RsaSecurityKey(RSA.Create(keySizeInBits: 2048)), SecurityAlgorithms.RsaSha256),

            _ => new(new RsaSecurityKey(RSA.Create(keySizeInBits: 2048)), SecurityAlgorithms.RsaSha256)
        });

        // Resolve the encryption credentials associated with the tenant (in a real world application,
        // the credentials would be retrieved from a persistent storage like a database or a key vault).
        options.EncryptionCredentials.Add(tenant switch
        {
            "fabrikam" => new(new RsaSecurityKey(RSA.Create(keySizeInBits: 2048)),
                SecurityAlgorithms.RsaOAEP, SecurityAlgorithms.Aes256CbcHmacSha512),

            _ => new(new RsaSecurityKey(RSA.Create(keySizeInBits: 2048)),
                SecurityAlgorithms.RsaOAEP, SecurityAlgorithms.Aes256CbcHmacSha512)
        });

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

Register the services in your DI container:

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

    services.AddOpenIddict()

        // Register the OpenIddict core components.
        .AddCore(options =>
        {
            options.UseEntityFrameworkCore()
                   .UseDbContext<ApplicationDbContext>();
        })

        // Register the OpenIddict server components.
        .AddServer(options =>
        {
            // Enable the authorization, device, introspection,
            // logout, token, userinfo and verification endpoints.
            options.SetAuthorizationEndpointUris("connect/authorize")
                   .SetDeviceEndpointUris("connect/device")
                   .SetIntrospectionEndpointUris("connect/introspect")
                   .SetLogoutEndpointUris("connect/logout")
                   .SetTokenEndpointUris("connect/token")
                   .SetUserinfoEndpointUris("connect/userinfo")
                   .SetVerificationEndpointUris("connect/verify");

            // Note: this sample uses the code, device code, password and refresh token flows, but you
            // can enable the other flows if you need to support implicit or client credentials.
            options.AllowAuthorizationCodeFlow()
                   .AllowDeviceCodeFlow()
                   .AllowPasswordFlow()
                   .AllowRefreshTokenFlow();

            // Mark the "email", "profile", "roles" and "demo_api" scopes as supported scopes.
            options.RegisterScopes(Scopes.Email, Scopes.Profile, Scopes.Roles, "demo_api");

            // Force client applications to use Proof Key for Code Exchange (PKCE).
            options.RequireProofKeyForCodeExchange();

            // Register the ASP.NET Core host and configure the ASP.NET Core-specific options.
            options.UseAspNetCore()
                   .EnableStatusCodePagesIntegration()
                   .EnableAuthorizationEndpointPassthrough()
                   .EnableLogoutEndpointPassthrough()
                   .EnableTokenEndpointPassthrough()
                   .EnableUserinfoEndpointPassthrough()
                   .EnableVerificationEndpointPassthrough();
        });

    services.AddSingleton<TenantProvider>();
    services.AddSingleton<IOptionsMonitor<OpenIddictServerOptions>, OpenIddictServerOptionsProvider>();
    services.AddSingleton<IConfigureOptions<OpenIddictServerOptions>, OpenIddictServerOptionsInitializer>();
}

To confirm this works correctly, navigate to https://localhost:[port]/fabrikam/.well-known/openid-configuration (you should get a JSON response with the OpenID Connect metadata).

Kévin Chalet
  • 39,509
  • 7
  • 121
  • 131
  • 1
    If I could upvote this a million times I would - thank you! – harman_kardon Nov 10 '18 at 10:09
  • 2
    Instead of modifying OptionsMonitor, I've modified OptionsCache to be tenant aware. I can thus make both IOptions and OptionsMonitor multitenant – Jeow Li Huan Dec 20 '18 at 09:22
  • @JeowLiHuan I've added an answer with an alternate approach using the same technique but with a custom implementation of IOptions using a multi tenant aware cache. – Dasith Wijes May 11 '20 at 13:26
  • Thank you very much for this guidance. I'm wondering where this OpenIddictServerOptions come from? OpenIddict.Server.OpenIddictServerOptions does not have a property DataProtectionProvider. – Obed Nov 08 '21 at 14:03
  • I see OpenIddictServerOptions used to inherit from OpenIdConnectServerOptions, where DataProtecionProvider was, but this was changed with commit 9ee38c0efb05b2413a0f768bb2694134bbbb68aa. Is this approach no longer recommended or is there another way to set the DataProtectionProvider per Tenant? – Obed Nov 08 '21 at 19:35
  • @JeowLiHuan old question here, but do you have an example of this posted up somewhere? – Tim Meers Dec 31 '21 at 18:05
  • @TimMeers My code is very similar to Dasith Wijes's answer. As for the configuration, you'll use the code in this answer `services.AddSingleton, OpenIddictServerOptionsInitializer>();`, but in `OpenIddictServerOptionsInitializer`, retrieve `var tenantId = multiTenantContextAccessor.MultiTenantContext?.TenantInfo?.Id ?? "";` and configure according to the tenant – Jeow Li Huan Jan 03 '22 at 03:55
  • @Obed, in the OpenIddictServerOptionsInitializer class replace OpenIddictServerOptions with OpenIddictServerDataProtectionOptions. Like you say, it's been changed in the latest version. – Neil L. Apr 13 '22 at 22:19
5

You're on the right track with the OAuth process. When you register the OpenID Connect scheme in your client web app's startup code, add a handler for the OnRedirectToIdentityProvider event and use that to add your "slug" value as the "tenant" ACR value (something OIDC calls the "Authentication Context Class Reference").

Here's an example of how you'd pass it to the server:

.AddOpenIdConnect("tenant", options =>
{
    options.CallbackPath = "/signin-tenant";
    // other options omitted
    options.Events = new OpenIdConnectEvents
    {
        OnRedirectToIdentityProvider = async context =>
        {
            string slug = await GetCurrentTenantAsync();
            context.ProtocolMessage.AcrValues = $"tenant:{slug}";
        }
    };
}

You didn't specify what sort of server this is going to, but ACR (and the "tenant" value) are standard parts of OIDC. If you're using Identity Server 4, you could just inject the Interaction Service into the class processing the login and read the Tenant property, which is automatically parsed out of the ACR values for you. This example is non-working code for several reasons, but it demonstrates the important parts:

public class LoginModel : PageModel
{
    private readonly IIdentityServerInteractionService interaction;
    public LoginModel(IIdentityServerInteractionService interaction)
    {
        this.interaction = interaction;
    }

    public async Task<IActionResult> PostEmailPasswordLoginAsync()
    {
        var context = await interaction.GetAuthorizationContextAsync(returnUrl);
        if(context != null)
        {
            var slug = context.Tenant;
            // etc.
        }
    }
}

In terms of identifying the individual user accounts, your life will be a lot easier if you stick to the OIDC standard of using "subject ID" as the unique user ID. (In other words, make that the key where you store your user data like the tenant "slug", the user email address, password salt and hash, etc.)

McGuireV10
  • 9,572
  • 5
  • 48
  • 64
5

For anyone who's interested in an alternative approach (more of an extension) to Kevin Chalet's accepted answer look at the pattern described here using a custom implementation of IOptions<TOption> as MultiTenantOptionsManager<TOptions> https://github.com/Finbuckle/Finbuckle.MultiTenant/blob/master/docs/Options.md

The authentication sample for the same pattern is here https://github.com/Finbuckle/Finbuckle.MultiTenant/blob/master/docs/Authentication.md

The full source code for the implemenation is here https://github.com/Finbuckle/Finbuckle.MultiTenant/blob/7bc72692b0f509e0348fe17dd3248d35f4f2b52c/src/Finbuckle.MultiTenant.Core/Options/MultiTenantOptionsManager.cs

The trick is using a custom IOptionsMonitorCache that is tenant aware and always returns a tenant scoped result https://github.com/Finbuckle/Finbuckle.MultiTenant/blob/7bc72692b0f509e0348fe17dd3248d35f4f2b52c/src/Finbuckle.MultiTenant.Core/Options/MultiTenantOptionsCache.cs

    internal class MultiTenantOptionsManager<TOptions> : IOptions<TOptions>, IOptionsSnapshot<TOptions> where TOptions : class, new()
    {
        private readonly IOptionsFactory<TOptions> _factory;
        private readonly IOptionsMonitorCache<TOptions> _cache; // Note: this is a private cache

        /// <summary>
        /// Initializes a new instance with the specified options configurations.
        /// </summary>
        /// <param name="factory">The factory to use to create options.</param>
        public MultiTenantOptionsManager(IOptionsFactory<TOptions> factory, IOptionsMonitorCache<TOptions> cache)
        {
            _factory = factory;
            _cache = cache;
        }

        public TOptions Value
        {
            get
            {
                return Get(Microsoft.Extensions.Options.Options.DefaultName);
            }
        }

        public virtual TOptions Get(string name)
        {
            name = name ?? Microsoft.Extensions.Options.Options.DefaultName;

            // Store the options in our instance cache.
            return _cache.GetOrAdd(name, () => _factory.Create(name));
        }

        public void Reset()
        {
            _cache.Clear();
        }
    }
public class MultiTenantOptionsCache<TOptions> : IOptionsMonitorCache<TOptions> where TOptions : class
    {
        private readonly IMultiTenantContextAccessor multiTenantContextAccessor;

        // The object is just a dummy because there is no ConcurrentSet<T> class.
        //private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, object>> _adjustedOptionsNames =
        //  new ConcurrentDictionary<string, ConcurrentDictionary<string, object>>();

        private readonly ConcurrentDictionary<string, IOptionsMonitorCache<TOptions>> map = new ConcurrentDictionary<string, IOptionsMonitorCache<TOptions>>();

        public MultiTenantOptionsCache(IMultiTenantContextAccessor multiTenantContextAccessor)
        {
            this.multiTenantContextAccessor = multiTenantContextAccessor ?? throw new ArgumentNullException(nameof(multiTenantContextAccessor));
        }

        /// <summary>
        /// Clears all cached options for the current tenant.
        /// </summary>
        public void Clear()
        {
            var tenantId = multiTenantContextAccessor.MultiTenantContext?.TenantInfo?.Id ?? "";
            var cache = map.GetOrAdd(tenantId, new OptionsCache<TOptions>());

            cache.Clear();
        }

        /// <summary>
        /// Clears all cached options for the given tenant.
        /// </summary>
        /// <param name="tenantId">The Id of the tenant which will have its options cleared.</param>
        public void Clear(string tenantId)
        {
            tenantId = tenantId ?? "";
            var cache = map.GetOrAdd(tenantId, new OptionsCache<TOptions>());

            cache.Clear();
        }

        /// <summary>
        /// Clears all cached options for all tenants and no tenant.
        /// </summary>
        public void ClearAll()
        {
            foreach(var cache in map.Values)
                cache.Clear();
        }

        /// <summary>
        /// Gets a named options instance for the current tenant, or adds a new instance created with createOptions.
        /// </summary>
        /// <param name="name">The options name.</param>
        /// <param name="createOptions">The factory function for creating the options instance.</param>
        /// <returns>The existing or new options instance.</returns>
        public TOptions GetOrAdd(string name, Func<TOptions> createOptions)
        {
            if (createOptions == null)
            {
                throw new ArgumentNullException(nameof(createOptions));
            }

            name = name ?? Microsoft.Extensions.Options.Options.DefaultName;
            var tenantId = multiTenantContextAccessor.MultiTenantContext?.TenantInfo?.Id ?? "";
            var cache = map.GetOrAdd(tenantId, new OptionsCache<TOptions>());

            return cache.GetOrAdd(name, createOptions);
        }

        /// <summary>
        /// Tries to adds a new option to the cache for the current tenant.
        /// </summary>
        /// <param name="name">The options name.</param>
        /// <param name="options">The options instance.</param>
        /// <returns>True if the options was added to the cache for the current tenant.</returns>
        public bool TryAdd(string name, TOptions options)
        {
            name = name ?? Microsoft.Extensions.Options.Options.DefaultName;
            var tenantId = multiTenantContextAccessor.MultiTenantContext?.TenantInfo?.Id ?? "";
            var cache = map.GetOrAdd(tenantId, new OptionsCache<TOptions>());

            return cache.TryAdd(name, options);
        }

        /// <summary>
        /// Try to remove an options instance for the current tenant.
        /// </summary>
        /// <param name="name">The options name.</param>
        /// <returns>True if the options was removed from the cache for the current tenant.</returns>
        public bool TryRemove(string name)
        {
            name = name ?? Microsoft.Extensions.Options.Options.DefaultName;
            var tenantId = multiTenantContextAccessor.MultiTenantContext?.TenantInfo?.Id ?? "";
            var cache = map.GetOrAdd(tenantId, new OptionsCache<TOptions>());

            return cache.TryRemove(name);
        }
    }

The advantage is you don't have to extend every type of IOption<TOption>.

It can be hooked up as shown in the example https://github.com/Finbuckle/Finbuckle.MultiTenant/blob/3c94ab2848758de7c9d0154aeddd4820dd545fbf/src/Finbuckle.MultiTenant.Core/DependencyInjection/MultiTenantBuilder.cs#L71

        private static MultiTenantOptionsManager<TOptions> BuildOptionsManager<TOptions>(IServiceProvider sp) where TOptions : class, new()
        {
            var cache = ActivatorUtilities.CreateInstance(sp, typeof(MultiTenantOptionsCache<TOptions>));
            return (MultiTenantOptionsManager<TOptions>)
                ActivatorUtilities.CreateInstance(sp, typeof(MultiTenantOptionsManager<TOptions>), new[] { cache });
        }

Using it https://github.com/Finbuckle/Finbuckle.MultiTenant/blob/3c94ab2848758de7c9d0154aeddd4820dd545fbf/src/Finbuckle.MultiTenant.Core/DependencyInjection/MultiTenantBuilder.cs#L43


 public static void WithPerTenantOptions<TOptions>(Action<TOptions, TenantInfo> tenantInfo) where TOptions : class, new()
   {
           // Other required services likes custom options factory, see the linked example above for full code

            Services.TryAddScoped<IOptionsSnapshot<TOptions>>(sp => BuildOptionsManager<TOptions>(sp));

            Services.TryAddSingleton<IOptions<TOptions>>(sp => BuildOptionsManager<TOptions>(sp));
    }

Every time IOptions<TOption>.Value is called it looks up the multi tenant aware cache to retrieve it. So you can conveniently use it in singletons like the IAuthenticationSchemeProvider as well.

Now you can register your tenant specific OpenIddictServerOptionsProvider options same as the accepted answer.

Dasith Wijes
  • 1,328
  • 12
  • 22
  • Seems like this is missing something to configure the OpenIddict options, like Authority. Maybe I'm just missing something? – Tim Meers Jan 02 '22 at 20:31