Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Closed
1 task done
InFarAday opened this issue Feb 27, 2023 · 10 comments
Closed
1 task done

Comments

@InFarAday
Copy link

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.

@kevinchalet
Copy link
Member

Hi @InFarAday,

Thanks for your interest!

FYI, as mentioned on the sponsors page, there's no support offered for professional projects in the first tier (it's reserved to tier 4 and higher):

image

Feel free to email me for additional details (or if your company would prefer a more traditional support option).
Cheers.

@InFarAday
Copy link
Author

Thanks for the feedback @kevinchalet, perhaps this is the better option
In fact I am trying to get OpenIddict to work in both my personal projects and my work. It is quite a struggle as management would prefer more "robust" (note the quotes) solutions like Azure B2C but I've been pushing towards OSS as a consequence of my personal experience. The current issue is related to my side projects because tenancy will almost certainly be resolved using the hostname at work.

If this qualifies as professional I will pay the t4 but I'll have to do it of my own pocket

@InFarAday
Copy link
Author

@kevinchalet
I have updated my membership to T4, I should have done that in the first place. Maintaining OSS is taxing and I don't want to cheap out on a great project that I will likely end up using in a professional setting.

With that out of the way I'm eager to hear what we can do to solve the issue at hand.

@kevinchalet
Copy link
Member

kevinchalet commented Feb 28, 2023

Hey,

@kevinchalet
I have updated my membership to T4, I should have done that in the first place. Maintaining OSS is taxing and I don't want to cheap out on a great project that I will likely end up using in a professional setting. With that out of the way I'm eager to hear what we can do to solve the issue at hand.

Thanks, much appreciated! 👍🏻

The user is simply signed-in using OpenIddictServerAspNetCoreDefaults.AuthenticationScheme.

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.

First, it's important to note that OpenIddictServerAspNetCoreDefaults.AuthenticationScheme is a very special scheme, that is always context-dependent and is only meant to be called while handling very specific requests (e.g token requests with an authorization code or refresh token): trying to call AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme) for an unsupported demand will always result in an InvalidOperationException being thrown. So even if you manage to get rid of the specific error you mentioned, you'll be later blocked by a different exception as soon as you'll try to configure Finbuckle to indirectly call AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme) for requests that are not supported.

Unfortunately, the situation you describe is basically a chicken-and-egg problem, where your Finbuckle configuration depends on the result of AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme) to resolve the tenant information. Yet, OpenIddict needs to make DB calls (to internally validate things like token entries) before a result can ever be returned by AuthenticateAsync(): no tenant info, no DB call possible ; no DB call, no tenant info: it's impossible to solve this way.

There are generally two common ways to approach multi-tenancy with OpenIddict:

  • By having a single OpenIddict server instance with a shared configuration and a shared DB that will be used by all the clients. acr_values or a custom authorization request parameter can be used by the clients to tell the server which tenant they'd like and such information can be persisted in the generated tokens so the clients and resource servers can make appropriate lookups.

  • By having multiple OpenIddict server instances (typically one per tenant) with a unique configuration - including different signing/encryption keys - and potentially separate databases. In this case, the resolution is typically hostname-based or based on things like application virtual paths/HttpRequest.PathBase. I detailed this option in https://stackoverflow.com/questions/49596938/openid-connect-identifying-tenant-during-login.

Other forms of multi-tenancy - such as the one you describe - are going to be much more complicated to implement, and more fragile. For some scenarios, you could probably use the OpenIddict events model (e.g for the code flow, to resolve the tenant ID from the token payload during the token request, you could likely add a custom handler that would set an AsyncLocal<T> based on a specific claim), but there are scenarios you couldn't support at all (e.g reference access or refresh tokens, for which no payload exists before accessing the DB).

TL;DR, you'll probably want to reconsider your approach. Adopting something hostname or HttpRequest.PathBase-based is generally a much better option.

Hope that helped.

@InFarAday
Copy link
Author

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.

@kevinchalet
Copy link
Member

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).

Vault is an excellent option for on-premises deployments. Alternatively, if you host your app on IIS, you could use Windows' built-in X.509 certificates store and deploy certificates yourself, but it's likely more work than going with Vault.
For cloud deployments, Azure Key Vault is a good option too.

  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?

Yep, sounds good. Depending on the scenario, you can also implement the tenant selection logic at the client level if the client itself is tenant-aware, so that the user is redirected to the correct OpenIddict instance without having to set the tenant ID via a form.

Cheers.

@InFarAday
Copy link
Author

Sorry for the late reply, I got sidetracked a bit.

Depending on the scenario, you can also implement the tenant selection logic at the client level if the client itself is tenant-aware

That certainly is an excellent idea, but my client app probably won't be tenant-aware.

In any case I'll go ahead and close the issue since I have my answer and I won't have the time to finish this project in the near future. I would still have a final question before closing it however.
In the Authorization Code flow, after I get back the authorization code and my client application retrieves it thanks to the callback url, am I correct in assuming that the client application should send the code to its back-end, which would then exchange it (using its client id and secret) for an access token (+ refresh token)? Of course it would then be sent back to the front-end and stored in the local storage.
The backchannel part is something I never managed to fully wrap my head around. I don't understand how it adds security.

Thanks and good evening.

@kevinchalet
Copy link
Member

kevinchalet commented Mar 6, 2023

In the Authorization Code flow, after I get back the authorization code and my client application retrieves it thanks to the callback url, am I correct in assuming that the client application should send the code to its back-end, which would then exchange it (using its client id and secret) for an access token (+ refresh token)? Of course it would then be sent back to the front-end and stored in the local storage.

In the typical scenario, the client application is either the frontend (for a SPA) or the backend (a server-side app), but not the two at the same time. In the first case, the OIDC flow is purely handled by the frontend and the identity is generally maintained in the local storage. In the second case, the OIDC flow is purely handled by the backend and the identity is generally stored as an authentication cookie (the backend-for-frontend pattern is an implementation of this approach).

The scenario you describe is way more rare in practice and we tend to use an alternative flow to implement it: the hybrid flow (which is basically a code flow where an identity and an access token can be directly returned during the first step, as part of the callback URI). In this scenario, the frontend generally starts the OIDC flow and handles the callback: it extracts the access/identity token(s) from the callback parameters and sends the authorization code to the backend, that will security redeem it to get separate access and identity tokens that won't be shared with the frontend (depending on the scenarios, the tokens retrieved via the backchannel call can give access to more resources than the tokens extracted by the frontend).

There's unfortunately a downside with this approach: since the frontend doesn't get a refresh token, you can't get a new access token after the original one expired. In the past, we used prompt=none authorization requests sent in an iframe as an alternative to refresh tokens, but this no longer works due to the ban of third-party cookies now enforced by most browser vendors. Due to this limitation, I wouldn't encourage you to implement this approach.

The backchannel part is something I never managed to fully wrap my head around. I don't understand how it adds security.

The main advantage with the backchannel part is that confidential web applications (i.e server-side web apps that have been given a client secret) can redeem the authorization code without the access/identity token(s) ever being visible by either the browser or the user himself, since the backchannel call is a direct HTTP request between the web app and the authorization server. Assuming the client secret never leaks, you can be fairly sure that the access token can only be used with the legitimate client itself, and not the user himself or any other party.

Hope it was clear 😃

Thanks again for sponsoring the project!

@InFarAday
Copy link
Author

I see. I typically do my things in microservices sitting behind a gateway, and ideally I'd want the authorization to be centralized on the gateway. Not all of them are to be exposed directly to the user either. For instance, I can have three microservices that are only available with my local network (or VPC) with a rigid definition and no frontend, like APIs, and a fourth that is exposed to the internet through the gateway at app1.example.com and would be an actual proper backend. I was eyeing Google Cloud as a provider since I find it simpler and more interesting to learn.

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.

Maybe I should open another issue and explain my project in more detail? If you don't want to mix questions. I'm not sure my initial choice of authorization code flow is the right one anymore.

Also, no problem! OpenIddict is great and I'm glad I don't have pay for IdentityServer. I need extensive customization and all the turnkey offerings have proven more complicated than doing it myself given my constraints.

@kevinchalet
Copy link
Member

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

2 participants