question-mark
Stuck on an issue?

Lightrun Answers was designed to reduce the constant googling that comes with debugging 3rd party libraries. It collects links to all the places you might be looking at while hunting down a tough bug.

And, if you’re still stuck at the end, we’re happy to hop on a call to see how we can help out.

Authorization code flow with multi-tenancy, database per tenant and claim-based tenant resolution

See original GitHub issue

Confirm you’ve already contributed to this project or that you sponsor it

  • I confirm I’m a sponsor or a contributor

Version

4.x

Question

Hello,

First of all as a disclaimer I must point out that I am neither an expert nor very experienced at all in OAuth or OIDC and that I may sometimes commit mistakes in the process itself ; eventually if my projects are to go public I will arrange to have it audited at minimal cost or will make sure that I did everything correctly. If however you see a simple oversight it would be very kind of you to point it out.

This is a very specific issue that I have been unable to solve so far, and I’ll try to be as detailed as possible. I am building, or rather making a POC at this point, of my own authentication server for my future projects. I have not been satisfied with turnkey solutions both because of their price and because I require extensive customization of the user model.

My tool stack regarding the authentication server is:

  • OpenIddict 4.x
  • Finbuckle.MultiTenant
  • ASP .NET Core Identity for the base user model and the helpers
  • A single PostgreSQL instance
  • Custom login interface using Razor pages

With this tool stack I intend to use a database per tenant, and a single “tenants” database to hold cross-tenant data. The tenants database should not hold any authentication data, it should only contain a single table with the tenant ids and the connection strings to the related databases. This is a constraint that I have as I want to keep a robust data isolation. This means that, for each tenant, the tenant’s authentication data (roles, usernames, passwords…) should be kept strictly within its own database.

I have setup a custom claim called <namespace>:tenant_id that should hold the tenant’s identifier. Finbuckle is set to resolve the tenant from that. I have two DbContexts:

  1. MultiTenancyContext with a single Tenant entity that relates to the tenants database as mentioned above. It is only used by Finbuckle to get the tenants’ connection strings from their identifiers
  2. AuthenticationContext with the full authentication data

I have setup my DI services so that the tenancy info is dynamically injected when the context is resolved. This looks like this:

public AuthenticationContext(ProviderConfiguration configuration, HashOrchestrator hashOrchestrator, TenantInfo? tenantInfo, DbContextOptionsBuilder<AuthenticationContext> optionsBuilder)
        : base(configuration.ApplyConnection(optionsBuilder, tenantInfo?.ConnectionString).Options)

ProviderConfiguration is a custom class which allows me to be provider agnostic by injecting the provider-dependent configuration only at the aggregation root (in Program.cs). HashOrchestrator is a custom helper because I use Argon2 as my hashing method and I need to have it in my OnConfiguring to seed some initial data for my migrations. TenantInfo is, as I said, what allows me to properly register the connection string.

For reference OpenIddict is setup this way:

builder.Services
    .AddOpenIddict()
    .AddCore(options => options
        .UseEntityFrameworkCore()
        .UseDbContext<AuthenticationContext>()
        .ReplaceDefaultEntities<int>())
    .AddServer(options =>
    {
        // Enregistrement des endpoints d'OpenIddict
        options.SetAuthorizationEndpointUris("/authorize");
        options.SetTokenEndpointUris("/token");
        options.SetUserinfoEndpointUris("/info");
        
        // Enregistrement des flux d'authentification autorisés
        options.AllowAuthorizationCodeFlow().RequireProofKeyForCodeExchange();
        options.AllowRefreshTokenFlow();
        
        options.SetAccessTokenLifetime(builder.Configuration.GetValue<TimeSpan>("Authentication:Lifetime:AccessToken"));
        options.SetRefreshTokenLifetime(builder.Configuration.GetValue<TimeSpan>("Authenthication:Lifetime:RefreshToken"));

        options.UseAspNetCore()
            .EnableTokenEndpointPassthrough()
            .EnableAuthorizationEndpointPassthrough();

        if (builder.Environment.IsDevelopment())
        {
            options.AddEphemeralEncryptionKey()
                .AddEphemeralSigningKey()
                .DisableAccessTokenEncryption();
        }
    })
    .AddValidation(options =>
    {
        options.UseLocalServer();
        options.UseAspNetCore();
    });

What happens from a linear POV is that when the user is redirected towards the login page, he is asked for three informations: his tenant id, his username and his password. The last two fields are thus unique only within the context of the first one. At this point the AuthenticationContext is injected into the LoginController but its connection string is null. Then the user submits the form and Finbuckle extracts the tenant id from that, as it uses a custom resolution strategy as fallback, like this:

builder.Services.AddMultiTenant<TenantInfo>()
    .WithClaimStrategy(MultiTenancyClaims.TenantId, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme)
    .WithClaimStrategy(MultiTenancyClaims.TenantId, CookieAuthenticationDefaults.AuthenticationScheme)
    .WithDelegateStrategy(context =>
    {
        if (context is not HttpContext httpContext || !httpContext.Request.HasFormContentType)
            return Task.FromResult<string?>(null);

        httpContext.Request.Form.TryGetValue(nameof(LoginViewModel.TenantId), out var tenantId);

        return Task.FromResult(tenantId.ToString())!;
    })
    .WithEFCoreStore<MultiTenancyContext, TenantInfo>();

As you can see, Finbuckle first tries to extract the tenant name from the OpenIddict’s authentication scheme. It falls back on the cookie authentication scheme that is used before retrieving the authorization code (but after checking the credentials), and if that fails it tries to extract it from the form data. This means that once the user has submitted the form I have a working AuthenticationContext injected into my LoginController. I then check that the credentials are correct, and if it is the case I redirect the user to its return url (if whitelisted of course). Excerpt from my LoginController:

var claims = new List<Claim>
{
    new(ClaimTypes.Name, user.UserName),
    new(MultiTenancyClaims.TenantId, Tenant.TenantInfo.Identifier)
};
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);

await HttpContext.SignInAsync(new ClaimsPrincipal(identity));

// TODO check whitelisted return url
if (viewModel.ReturnUrl is not null)
    return Redirect(viewModel.ReturnUrl);

// TODO redirect to default page
return Ok();

After that the user is signed in with a cookie, and goes to /authorize to get an authorization code. Once again Finbuckle resolves the tenant from the cookie’s claim and a working AuthenticationContext is injected in the AuthorizationController. I can provide the code but the issue message is already long so I won’t include it here, it’s pretty standard. The user is simply signed-in using OpenIddictServerAspNetCoreDefaults.AuthenticationScheme.

So far everything works and has been successfully implemented and tested under the condition that I remove the first claim strategy (the one that uses OpenIddict’s autentication scheme). But eventually the tenant will have to be resolved from something else than the cookie ; this is the case with the call at /token. Finbuckle has a middleware to inject the tenant called in Program.cs with UseMultiTenant(). This middleware iterates over each strategy and tries to find one that returns a proper tenant identifier. For claim strategies, it tries to authenticate the user using HttpContext.AuthenticateAsync(). For cookies the authentication silently fails and Finbuckle tries the next strategy. With OpenIddict however I get the following error:

InvalidOperationException: An error occurred while retrieving the OpenIddict server context. On ASP.NET Core, this may indicate that the authentication middleware was not registered early enough in the request pipeline. Make sure that 'app.UseAuthentication()' is registered before 'app.UseAuthorization()' and 'app.UseEndpoints()' (or 'app.UseMvc()') and try again.

While misguided this provides a clue as to what OpenIddict expects: that no authentication should happen before the authentication middleware UseAuthentication has run. Obviously I tried placing UseAuthentication() higher than UseMultiTenant() but this cannot work because OpenIddict tries to make a database call with a context that has a null connection string, as Finbuckle hasn’t retrieved it yet.

Just to be clear:

  • If WithClaimStrategy(MultiTenancyClaims.TenantId, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme) is kept the authentication server does not even launch, or rather I get the error above when ASP tries injecting the AuthenticationContext in the LoginController that is used to both display the login form and validate the credentials.
  • If it is not used, which I tested, everything works up to /token, that is once the user is signed-in using OpenIddict’s scheme. After that Finbuckle can’t resolve the tenant from the cookie anymore.

I hope my issue is as clear as it can be. I don’t think we will be able to solve it exactly the way I want, so I’m open to suggestions or alternative ways to do it. But I very much would like to keep my tenancy claim-based, whilst also having a database per tenant in regard to authentication data. I’ve thought of moving OpenIddict to the cross-tenant database but this would require moving UseAuthentication() above UseMultiTenant() and I’m not sure it would solve anything or be secure.

Thank you very much.

Issue Analytics

  • State:closed
  • Created 7 months ago
  • Comments:10 (5 by maintainers)

github_iconTop GitHub Comments

1reaction
CorentinBrossutti1commented, Mar 1, 2023

This was my exact line of reasoning as well. I was hoping that maybe there was something I would have missed. I found claim-based resolution with a single unified login screen more streamlined in terms of UX but I guess I’ll settle for hostname-based resolution, which IMO is the next best option.

And indeed it works now with the solution you posted on SO. That does mean I will have to store the tenants’ signing keys somewhere though, and I’m not sure I feel comfortable having them as plaintext in the database. Do you have recommendations on that? The only viable non-expensive solution I see is https://github.com/hashicorp/vault (used by GitLab as well IIRC).

To wrap it up, I mostly used https://github.com/robinvanderknaap/authorization-server-openiddict/blob/main/AuthorizationServer/Controllers/AccountController.cs as a sample to implement to base myself upon for my solution. My typical user flow with hostname-based tenancy resolution would be:

  1. Entry point A: user enters my project’s URL without a subdomain ; he gets the login screen with the tenant id field which goes directly to 3. with username and password.
  2. Entry point B: user enters a tenant’s URL like <tenantId>.host.xxx, he gets a standard login screen.
  3. Post-login action: user is authenticated and the return url at this point should be /authorize.
  4. User is redirected to /authorize and the normal authentication code flow happens.

Does that sound correct to you?

Thanks.

0reactions
kevinchaletcommented, Mar 10, 2023

Woops, sorry for the late reply, I somehow missed the notification 😅

Maybe I should open another issue and explain my project in more detail? If you don’t want to mix questions.

Sure, feel free.

When the user tries to access app1.example.com he would either be logged in already or redirected to the authorization server that would serve him a login page. My idea was that app1 would retrieve the authorization code (so the frontend would have a callback route, get the code and send it to the backend) and exchange it for an access token that is valid for all apps sitting behind the gateway. The apps and the gateway would know each other so that I could have an “admin” app to create users for your tenant, etc. Non-exposed services wouldn’t check authentication since they would assume the calling application did it for them.

I would personally keep it simple by choosing either the SPA or the backend as the unique client handling the authorization dance from A to Z rather than having an “hybrid” client (for the reasons I mentioned in my previous post):

  • Let’s say you implement OIDC at the SPA level: in this case, it will directly communicate with the authorization server and will directly make API calls to the resource servers (you can have a reverse proxy between the two if preferred).
  • Let’s say you implement OIDC at the backend level and use authentication cookies between the SPA and the backend (aka the BFF pattern): in this case, the OIDC is handled purely by the backend and any API calls made by the SPA must be directed to the backend, that will proxy it to the actual resource servers.

Cheers.

Read more comments on GitHub >

github_iconTop Results From Across the Web

How to implement claim-based multi-tenancy with ...
I'm set on a multi-tenant architecture using a db per tenant and Finbuckle ... the user is directed towards /authorize to retrieve his...
Read more >
Authorization Challenges in a Multitenant System
Restricting users to the data that belongs to their tenant is the most fundamental requirement of multitenant authorization.
Read more >
Architectural considerations for identity in a multitenant ...
For a multitenant solution to authenticate and authorize a user or service, it needs a place to store identity information.
Read more >
Using OpenID Connect (OIDC) Multi-Tenancy
This guide demonstrates how your OpenID Connect (OIDC) application can support multi-tenancy so that you can serve multiple tenants from a single ...
Read more >
Multi-tenant SaaS database tenancy patterns - Azure
Learn about the requirements and common data architecture patterns of multi-tenant software as a service (SaaS) database applications that ...
Read more >

github_iconTop Related Medium Post

No results found

github_iconTop Related StackOverflow Question

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Dev.to Post

No results found

github_iconTop Related Hashnode Post

No results found