Authorization code flow with multi-tenancy, database per tenant and claim-based tenant resolution
See original GitHub issueConfirm 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:
MultiTenancyContext
with a singleTenant
entity that relates to the tenants database as mentioned above. It is only used by Finbuckle to get the tenants’ connection strings from their identifiersAuthenticationContext
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 theAuthenticationContext
in theLoginController
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:
- Created 7 months ago
- Comments:10 (5 by maintainers)
Top GitHub Comments
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:
<tenantId>.host.xxx
, he gets a standard login screen./authorize
./authorize
and the normal authentication code flow happens.Does that sound correct to you?
Thanks.
Woops, sorry for the late reply, I somehow missed the notification 😅
Sure, feel free.
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):
Cheers.