Multitenant OAuth Server with OpenIdDict and Multitenant Authorities for JWT Authentication in ASP

Siarhei Kharlap
8 min readSep 8, 2023

--

Photo by Florian Berger on Unsplash

Recently we’ve developed the OAuth server with the OpenIdDict library. You can learn more about this library and find a sample in my other story: OpenId Connect and OAuth2.0 server in AspNetCore using OpenIdDict.

Since then, we’ve also encountered another challenge: while developing a multi-tenant application for security reasons, we needed to restrict each tenant with its unique security credentials, such as certificates, and more. During my investigation, I ran into a question on question on stack overflow where I got an idea about the desired solution. The concept of Tenanted Options was born from this idea, and you can find more details about their implementation in my related article: Tenanted Options for Multitenant Applications in ASP. This article, on the other hand, focuses on the implementation journey of the entire system.

Asymption for the tenant
1. Tenant 100% can be resolved when it is needed
2. Tenant isn’t changed during the request

Recalling that Tenanted Options require an ITenantProvider.

In this particular example, we assume that the tenant is present in the URL (while in a real-life application in my project, we retrieve it from the host domain).
Given our assumption that a tenant exists within the request scope, it’s logical to store it in HttpContext. To achieve this, I’ll use the following approach:

public interface ITenantFeature
{
string TenantId { get; set; }
}
public class TenantFeature : ITenantFeature
{
public string TenantId { get; set; }
}
public class HttpContextTenantProvider : ITenantProvider
{
private readonly IHttpContextAccessor _httpContextAccessor;

public HttpContextTenantProvider(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor));
}

public string GetCurrentTenant()
{
return _httpContextAccessor.HttpContext.Features.Get<ITenantFeature>()?.TenantId ?? throw new Exception("Tenant feature wasn't provided!");
}
}

And middleware to populate this feature on request start:

app.Use(async (context, next) =>
{
var firstPathPart = context.Request.Path.Value.Split('/')[1];

var tenantFeature = new TenantFeature
{
TenantId = firstPathPart
};
context.Features.Set<ITenantFeature>(tenantFeature);

context.Request.Path = context.Request.Path.Value.Replace($"/{firstPathPart}", "");
context.Request.PathBase += $"/{firstPathPart}";
var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();
logger.LogInformation($"New request for tenant: {tenantFeature.TenantId}");
await next(context);
});

It is also important to use another middleware to work with Path and PathBase correctly: app.UseRouting();

I believe we have covered the preparation part, so let’s move forward with the applications.

Multitenant OAuth Server with OpendIdDict

As I’ve mentioned earlier, I have another article where I demonstrate how to build a sample OAuth Server with OpenIdDict. In this article, I will refer to it and focus solely on making it multitenant, without delving into the OpenIdDict details.

Starting from endpoint to issue tokens where we support only client credentials flow:

private readonly IOpenIddictScopeManager _scopeManager;

[HttpPost("~/token"), IgnoreAntiforgeryToken, Produces("application/json")]
public async Task<IActionResult> Exchange()
{
var request = HttpContext.GetOpenIddictServerRequest()
?? throw new InvalidOperationException("The OpenID Connect request cannot be retrieved.");

if (request.IsClientCredentialsGrantType())
{
var clientId = request.ClientId;
var identity = new ClaimsIdentity(authenticationType: TokenValidationParameters.DefaultAuthenticationType);
identity.SetClaim(Claims.Subject, clientId);
identity.SetScopes(request.GetScopes());
var principal = new ClaimsPrincipal(identity);
principal.SetResources(await _scopeManager.ListResourcesAsync(principal.GetScopes()).ToListAsync());

return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}

throw new NotImplementedException("The specified grant type is not implemented.");
}

And Program.cs:

using Microsoft.EntityFrameworkCore;
using OAuthServer;
using OpenIddict.Core;
using OpenIddict.Server;
using TenantedOptions.Core;
using TestApplication.Multitenancy;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<DbContext>(options =>
{
options.UseInMemoryDatabase(nameof(DbContext));
options.UseOpenIddict();
});
builder.Services.AddHttpContextAccessor();
builder.Services.AddTenantedOptions<HttpContextTenantProvider, OpenIddictServerOptions>();
builder.Services.AddSingleton<IConfigureTenantedOptions<OpenIddictServerOptions>, ConfigureCertificatesOpenIddictServerOptions>();
builder.Services.AddOpenIddict()
.AddCore(
options =>
{
options.UseEntityFrameworkCore()
.UseDbContext<DbContext>();
})
.AddServer(
options =>
{
options.AllowClientCredentialsFlow();
options.SetTokenEndpointUris("token");
options.DisableAccessTokenEncryption();
options.UseAspNetCore()
.EnableTokenEndpointPassthrough();
});
builder.Services.AddControllers();
builder.Services.AddHostedService<ClientSeeder>();

var app = builder.Build();
app.Use(async (context, next) =>
{
var firstPathPart = context.Request.Path.Value.Split('/')[1];

var tenantFeature = new TenantFeature
{
TenantId = firstPathPart
};
context.Features.Set<ITenantFeature>(tenantFeature);

context.Request.Path = context.Request.Path.Value.Replace($"/{firstPathPart}", "");
context.Request.PathBase += $"/{firstPathPart}";
var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();
logger.LogInformation($"New request for tenant: {tenantFeature.TenantId}");
await next(context);
});
app.UseHttpsRedirection();
app.UseRouting();
app.UseAuthentication();
app.MapControllers();

app.Run();

If you compare the configuration of OpenIdDict with the non-multi-tenant version to achieve the same functionality, the only difference lies in:

builder.Services.AddTenantedOptions<HttpContextTenantProvider, OpenIddictServerOptions>();
builder.Services.AddSingleton<IConfigureTenantedOptions<OpenIddictServerOptions>, ConfigureCertificatesOpenIddictServerOptions>();

The first line enables Tenanted Options for the OpenIddictServerOptions.
While ConfigureCertificatesOpenIddictServerOptions:

public class ConfigureCertificatesOpenIddictServerOptions : IConfigureTenantedOptions<OpenIddictServerOptions>
{
private readonly IConfiguration _configuration;
private readonly ILogger<ConfigureCertificatesOpenIddictServerOptions> _logger;

public ConfigureCertificatesOpenIddictServerOptions(
IConfiguration configuration,
ILogger<ConfigureCertificatesOpenIddictServerOptions> logger
)
{
_configuration = configuration;
_logger = logger;
}

public void Configure(string name, string tenant, OpenIddictServerOptions options)
{
_logger.LogInformation("Configuring OpenIddictServerOptions for tenant {tenant}", tenant);
string cerdata;

if (tenant.Equals("tenant1", StringComparison.OrdinalIgnoreCase))
{
cerdata = _configuration["OpenIddict:Certificate1"];
}
else
{
cerdata = _configuration["OpenIddict:Certificate2"];
}

var cer = new X509Certificate2(
Convert.FromBase64String(cerdata),
password: (string)null,
keyStorageFlags: X509KeyStorageFlags.EphemeralKeySet
);
var signingCertificate = cer;
options.SigningCredentials.Add(
new(new X509SecurityKey(signingCertificate), SecurityAlgorithms.RsaSha256)
);

var encryptionCertificate = cer;
options.EncryptionCredentials.Add(
new(new X509SecurityKey(encryptionCertificate), SecurityAlgorithms.RsaOAEP, SecurityAlgorithms.Aes256CbcHmacSha512)
);
}
}

With Tenanted Options introducing the new IConfigureTenantedOptions to compose the final options object, we can rely on it to implement tenancy in this example and obtain different certificates for each tenant1 and tenantX. We extract the tenant from the request, and the only concern is providing an appropriate configuration for each tenant. As an example, I’ve generated two certificates with private keys (for simplicity, I’ll only operate with two tenants) and included them in appsettings.json so they are accessible via the IConfiguration interface. In a production environment, you would typically manage and provide these certificates through a dedicated third-party service like Azure Key Vault.

Moving on the middleware part, you’ve already seen it in the previous section:

app.Use(async (context, next) =>
{
var firstPathPart = context.Request.Path.Value.Split('/')[1];

var tenantFeature = new TenantFeature
{
TenantId = firstPathPart
};
context.Features.Set<ITenantFeature>(tenantFeature);

context.Request.Path = context.Request.Path.Value.Replace($"/{firstPathPart}", "");
context.Request.PathBase += $"/{firstPathPart}";
var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();
logger.LogInformation($"New request for tenant: {tenantFeature.TenantId}");
await next(context);
});
...
app.UseRouting();

The only additional point to note is the importance of rewriting PathBase, as it plays a crucial role in enabling OpenIdDict to determine the issuer based on the host and PathBase. You can compare this to the following example

and cases when PathBase wasn’t edited

I’ve also registered the OAuth client via the ClientSeeder hosted service:

public class ClientSeeder : IHostedService
{
private readonly IServiceProvider _serviceProvider;

public ClientSeeder(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}

public async Task StartAsync(CancellationToken cancellationToken)
{
using var scope = _serviceProvider.CreateScope();

var databaseContext = scope.ServiceProvider.GetRequiredService<DbContext>();
databaseContext.Database.EnsureCreated();

await PopulateScopes(scope, cancellationToken);

await PopulateInternalApps(scope, cancellationToken);
}

private async ValueTask PopulateScopes(IServiceScope scope, CancellationToken cancellationToken)
{
var scopeManager = scope.ServiceProvider.GetRequiredService<IOpenIddictScopeManager>();

var scopeDescriptor = new OpenIddictScopeDescriptor
{
Name = "test_scope",
Resources = { "test_resource" }
};

var scopeInstance = await scopeManager.FindByNameAsync(scopeDescriptor.Name, cancellationToken);

if (scopeInstance == null)
{
await scopeManager.CreateAsync(scopeDescriptor, cancellationToken);
}
else
{
await scopeManager.UpdateAsync(scopeInstance, scopeDescriptor, cancellationToken);
}
}

private async ValueTask PopulateInternalApps(IServiceScope scopeService, CancellationToken cancellationToken)
{
var appManager = scopeService.ServiceProvider.GetRequiredService<IOpenIddictApplicationManager>();

var appDescriptor = new OpenIddictApplicationDescriptor
{
ClientId = "test_client",
ClientSecret = "test_secret",
Type = OpenIddictConstants.ClientTypes.Confidential,
Permissions =
{
OpenIddictConstants.Permissions.Endpoints.Token,

OpenIddictConstants.Permissions.GrantTypes.ClientCredentials,

OpenIddictConstants.Permissions.Prefixes.Scope + "test_scope"
}
};

var client = await appManager.FindByClientIdAsync(appDescriptor.ClientId, cancellationToken);

if (client == null)
{
await appManager.CreateAsync(appDescriptor, cancellationToken);
}
else
{
await appManager.UpdateAsync(client, appDescriptor, cancellationToken);
}
}

public Task StopAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
}

Now, we are even able to issue tokens for our tenants:

As you can see, the issuer contains our tenant. It’s worth noting that you can achieve this without Tenanted Options if you don’t require tenant-specific configurations. In such cases, middleware that overrides PathBase is sufficient. However, to demonstrate that keys also differ, you can inspect the jwks endpoints for tenant1 and tenant2. (JWKS is used to verify the signature on the client app.)

You may notice that they differ.
And that’s it for the OAuth server.

Client Application and Multitenant Authority

While we’ve already seen how Tenanted Options works for the OAuth server, let’s now explore how to consume them in a client application to gain a complete understanding of the solution.

The example application consists of only one endpoint that requires users to be authorized and returns the current tenant:

[ApiController]
[Route("[controller]")]
[Authorize]
public class TestController : ControllerBase
{
private readonly ITenantProvider _tenantProvider;

public TestController(ITenantProvider tenantProvider)
{
_tenantProvider = tenantProvider;
}

[HttpGet]
public IActionResult Get()
{
return Ok(_tenantProvider.GetCurrentTenant());
}
}

Also we are using the following middlewares:

app.Use(async (context, next) =>
{
var firstPathPart = context.Request.Path.Value.Split('/')[1];
var tenantFeature = new TenantFeature
{
TenantId = firstPathPart
};
context.Features.Set<ITenantFeature>(tenantFeature);
context.Request.Path = context.Request.Path.Value.Replace($"/{firstPathPart}", "");
context.Request.PathBase += $"/{firstPathPart}";
var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();
logger.LogInformation($"New request for tenant: {tenantFeature.TenantId}");
await next(context);
});

app.UseHttpsRedirection();
app.UseAuthentication();
app.UseRouting();
app.UseAuthorization();
app.MapControllers();
app.Run();

And to add a plain authentication with JWT we don’t need many things just:

builder.Services.AddAuthentication("Bearer")
.AddJwtBearer("Bearer", options =>
{
//options.Authority = "https://authority..."
options.Audience = "test_resource";
});

When we want to deal with a multi-tenant authority, it becomes a bit more complicated, as AddJwtBearer allows you to have only one authority. I’ve seen suggestions across the internet to add more schemes like:
.AddJwtBearer(“Bearer1”) .AddJwtBearer(“Beare2”) … for the required authorities, and it may work, even if it is performance-killing and inconvenient, but it only works when your tenants are static. However, when they are created and managed dynamically, things become more complicated.

So, instead of adding them one by one, I can use Tenanted Options::

builder.Services.AddTenantedOptions<HttpContextTenantProvider, JwtBearerOptions>();
builder.Services.AddSingleton<IConfigureTenantedOptions<JwtBearerOptions>>(
sf => ActivatorUtilities.CreateInstance<ConfigureAuthorityJwtBearerOptions>(sf, "https://localhost:7041")
);
builder.Services.AddAuthentication("Bearer")
.AddJwtBearer("Bearer", options =>
{
//ptions.Authority = "https://authority..."
options.Audience = "test_resource";
});

Note that in this case, ConfigureAuthorityJwtBearerOptions is registered so complicated just to inject a logger inside it for a demo purpose.
And the ConfigureAuthorityJwtBearerOptions:

public class ConfigureAuthorityJwtBearerOptions : IConfigureTenantedOptions<JwtBearerOptions>
{
private string _authority;
private readonly ILogger<ConfigureAuthorityJwtBearerOptions> _logger;

public ConfigureAuthorityJwtBearerOptions(string authority, ILogger<ConfigureAuthorityJwtBearerOptions> logger)
{
_authority = authority;
_logger = logger;
}

public void Configure(string name, string tenant, JwtBearerOptions options)
{
options.Authority = _authority+"/"+tenant;
_logger.LogInformation("configured authority: {authority}", options.Authority);
}
}

And actually, this is the core logic that you need.
Let’s attempt to send a request without tokens:

Issue a token and use it:

But if the same token is sent to a different tenant we will get 401:

And the final thing is logging:

We log each request in the middleware along with tenant retrieving.
Also, we log inside the ConfigureAuthorityJwtBearerOptions when it is called by the factory, but as you can see it occurs only on the first request to a tenant, and then it is appropriately cached.

And that’s a wrap!

The code from the article can be found on the GitHub repo

Thank you for sticking with me during this article, I hope that you find it easy and useful.
Also, if you enjoyed this story and found it insightful, be sure to follow me for more engaging content like this in the future.
You can also follow me on LinkedIn where I post notifications about my new publication with free access links.

If you have an interest in the Options Pattern itself, you may be interested in my other article: Options Pattern in Asp Net Core: Easier than you think!

If you want to read about Tenanted Options, then follow my other story: Tenanted Options for Multitenant Applications in ASP.

--

--