Navigate back to the homepage

How to build a multi-tenant SaaS solution with Spring

Johannes Matt
April 12th, 2022 · 3 min read

As we have seen in the first part of this series, designing a multi-tenant SaaS solution requires making important decisions about your target system architecture before the start of the implementation. We have decided to use Spring Framework and OAuth2 as the main building blocks for implementing our application.

In this article we will be looking at how to implement one of the core functionalities of every multi-tenant web application: Resolving the tenant. In general, this can be done in multiple ways, e.g. extracting it from an HTTP header would be a simple but surely not a production-ready way. Since we are using OAuth2 as the authorization protocol, in this article we will focus specifically on how to resolve the tenant by the OAuth2 issuer claim.

Ok, now show me the code!

Alright, enough talking - let's get to coding!

If you want to follow all of the steps in this guide, you need the following Maven dependencies in your Java application:

1<dependencies>
2 <dependency>
3 <groupId>org.springframework.boot</groupId>
4 <artifactId>spring-boot-starter-web</artifactId>
5 </dependency>
6 <dependency>
7 <groupId>org.springframework.boot</groupId>
8 <artifactId>spring-boot-starter-security</artifactId>
9 </dependency>
10 <dependency>
11 <groupId>org.springframework.boot</groupId>
12 <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
13 </dependency>
14</dependencies>

Defining the allowed tenants

Since we want to restrict system access to a list of known tenants, the first step we need to do is to define a JwtIssuerAuthenticationManagerResolver which ensures that every token's issuer is verified against a list of allowed and well-known tenants.

1JwtIssuerAuthenticationManagerResolver authenticationManagerResolver =
2 new JwtIssuerAuthenticationManagerResolver(
3 "https://idp.example.org/tenant-1",
4 "https://idp.example.org/tenant-2"
5 );
6
7http
8 .authorizeHttpRequests(authz -> authz
9 .anyRequest().authenticated()
10 )
11 .oauth2ResourceServer(oauth2 -> oauth2
12 .authenticationManagerResolver(authenticationManagerResolver)
13 );

This list of specified tenants serves as a whitelist. If the current request's token is issued by a tenant that is not part of the whitelist, the request will be denied by returning HTTP status 401 (unauthorized).

Making the tenant whitelist dynamic

To avoid relying on a hard-coded (static) list of tenants, we want to make the tenant whitelist dynamic so that it can be updated during runtime. We can achieve this by creating a tenant-aware AuthenticationManagerResolver which maintains a repository (in this case a simple Map) of individual authentication managers. Each manager is responsible for resolving the authentication of a specific tenant.

1/**
2 * A tenant-aware implementation of an AuthenticationManagerResolver that holds a repository of all
3 * authentication managers. Each manager is responsible for resolving the authentication of a
4 * specific tenant.
5 *
6 * @see AuthenticationManagerResolver
7 */
8@Component
9public class MultiTenantAuthenticationManagerResolver implements AuthenticationManagerResolver<HttpServletRequest> {
10
11 private final TenantService tenantService;
12 private final JwtDecoder jwtDecoder;
13 private final BearerTokenResolver resolver = new DefaultBearerTokenResolver();
14 private final Map<String, AuthenticationManager> authenticationManagers = new ConcurrentHashMap<>();
15
16 public MultiTenantAuthenticationManagerResolver(TenantService tenantService, JwtDecoder jwtDecoder) {
17 this.tenantService = tenantService;
18 this.jwtDecoder = jwtDecoder;
19 }
20
21 @Override
22 public AuthenticationManager resolve(HttpServletRequest request) {
23 return authenticationManagers.computeIfAbsent(toTenant(request), this::fromTenant);
24 }
25
26 private String toTenant(HttpServletRequest request) {
27 try {
28 String token = resolver.resolve(request);
29 return JWTParser.parse(token).getJWTClaimsSet().getIssuer();
30 } catch (Exception e) {
31 throw new IllegalArgumentException(e);
32 }
33 }
34
35 private AuthenticationManager fromTenant(String tenant) {
36 return tenantService.getByIssuer(tenant)
37 .map(Tenant::getIssuer)
38 .map(i -> new JwtAuthenticationProvider(jwtDecoder))
39 .orElseThrow(() -> new InvalidBearerTokenException("Unknown tenant: " + tenant))
40 ::authenticate;
41 }
42
43}

Note that it's important to pre-verify the issuer before adding it to the authentication manager repository. For this, we are using an exemplary TenantService which is responsible for verifying the issuer against a list of allowed issuers (see the highlighted line). This is usually done by checking the issuer against a list of allowed tenants in your database.

Parsing the claim only once

By default, each token is parsed by the JwtDecoder. Since we have additionally introduced an authentication manager resolver, each token would be parsed twice: once by the AuthenticationManagerResolver and another time by the default JwtDecoder.

To prevent parsing the token multiple times in each request, we are going to create our own JwtDecoder bean. For this, we need to configure the following classes:

1. Defining a tenant-aware key selector

The first step is to define a JWTClaimsSetAwareJWSKeySelector from Nimbus.

1/**
2 * An implementation of a JWTClaimsSetAwareJWSKeySelector that selects key candidates based on the
3 * issuer of the provided JWT. The issuer needs to be well-known by the tenantService which
4 * is responsible for checking the issuer against a whitelist of allowed tenants.
5 *
6 * @see JWTClaimsSetAwareJWSKeySelector
7 */
8@Component
9public class MultiTenantJWSKeySelector implements JWTClaimsSetAwareJWSKeySelector<SecurityContext> {
10
11 private final TenantService tenantService;
12 private final Map<String, JWSKeySelector<SecurityContext>> selectors = new ConcurrentHashMap<>();
13
14 public MultiTenantJWSKeySelector(TenantService tenantService) {
15 this.tenantService = tenantService;
16 }
17
18 @Override
19 public List<? extends Key> selectKeys(JWSHeader jwsHeader,
20 JWTClaimsSet jwtClaimsSet,
21 SecurityContext securityContext) throws KeySourceException {
22
23 return selectors.computeIfAbsent(toTenant(jwtClaimsSet), this::fromTenant)
24 .selectJWSKeys(jwsHeader, securityContext);
25 }
26
27 private String toTenant(JWTClaimsSet claimSet) {
28 return claimSet.getIssuer();
29 }
30
31 private JWSKeySelector<SecurityContext> fromTenant(String issuer) {
32 return tenantService.getByIssuer(issuer)
33 .map(Tenant::getJwkSetUrl)
34 .map(this::fromUri)
35 .orElseThrow(() -> new IllegalArgumentException("Unknown tenant"));
36 }
37
38 private JWSKeySelector<SecurityContext> fromUri(String uri) {
39 try {
40 return JWSAlgorithmFamilyJWSKeySelector.fromJWKSetURL(new URL(uri));
41 } catch (Exception e) {
42 throw new IllegalArgumentException(e);
43 }
44 }
45
46}

2. Using the defined key selector

Once we have defined the key selector, we can use it in a custom JWT processor.

1@Bean
2JWTProcessor jwtProcessor(JWTClaimSetJWSKeySelector keySelector) {
3 ConfigurableJWTProcessor<SecurityContext> jwtProcessor = new DefaultJWTProcessor();
4 jwtProcessor.setJWTClaimsSetAwareJWSKeySelector(keySelector);
5 return jwtProcessor;
6}

3. Defining a tenant-aware issuer validator

Then we need to define an OAuth2TokenValidator.

1/**
2 * An implementation of an OAuth2TokenValidator that validates the issuer of a JWT against a whitelist
3 * of allowed tenants. The allowed tenants are managed by the tenantService.
4 *
5 * @see OAuth2TokenValidator
6 */
7@Component
8public class MultiTenantJwtIssuerValidator implements OAuth2TokenValidator<Jwt> {
9
10 private final TenantService tenantService;
11 private final Map<String, JwtIssuerValidator> validators = new ConcurrentHashMap<>();
12
13 public MultiTenantJwtIssuerValidator(TenantService tenantService) {
14 this.tenantService = tenantService;
15 }
16
17 @Override
18 public OAuth2TokenValidatorResult validate(Jwt token) {
19 return validators.computeIfAbsent(toTenant(token), this::fromTenant)
20 .validate(token);
21 }
22
23 private String toTenant(Jwt jwt) {
24 return jwt.getIssuer().toString();
25 }
26
27 private JwtIssuerValidator fromTenant(String issuer) {
28 return tenantService.getByIssuer(issuer)
29 .map(Tenant::getIssuer)
30 .map(JwtIssuerValidator::new)
31 .orElseThrow(() -> new IllegalArgumentException("Unknown tenant"));
32 }
33
34}

4. Putting all pieces together

Finally, we can create our own JwtDecoder bean by using the previously defined JWT processor (which in turn uses the tenant-aware JWS key selector) and the OAuth2 token validator.

1@Bean
2JwtDecoder jwtDecoder(JWTProcessor jwtProcessor, OAuth2TokenValidator<Jwt> jwtValidator) {
3 NimbusJwtDecoder decoder = new NimbusJwtDecoder(jwtProcessor);
4 OAuth2TokenValidator<Jwt> validator = new DelegatingOAuth2TokenValidator<>
5 (JwtValidators.createDefault(), jwtValidator);
6 decoder.setJwtValidator(validator);
7 return decoder;
8}

Confused? 🧐 Then keep reading

If you are a Java developer with at least some basic knowledge of Spring, but still struggling to follow the above steps, then the following advice might be right for you. I had a hard time as well trying to understand how all of these pieces come together when I implemented the core framework of the Quantics SaaS solution.

That's why I decided to create a Spring Boot starter library that makes the setup of a new multi-tenant application as easy as possible for everyone. Since I didn't know exactly how to best do that, I reached out to Josh Cummings, who is one of the main Spring Security contributors, to ask for advice and feedback on my code. Thankfully, Josh gave me some truly useful tips on how to improve the code, and eventually, I decided to open-source it. You can find the library on Quantics' GitHub account here.

To understand how all the individual code parts in this article are coming together in a real-world application, I will present a sample app in part 3 of this series. This application will be using the Spring Boot starter library and we will see how easy it is for anyone to use it for implementing their own multi-tenant application.

Update: The third part of the series is available here.


References

More articles from Johannes Matt

How to design a multi-tenant SaaS solution

In this new series of blog posts, we will look at how to design and build a multi-tenant SaaS solution from scratch

February 1st, 2022 · 5 min read

How to make your multi-tenant Spring app production-ready

Tools & libraries to make your multi-tenant Spring app production-ready

December 3rd, 2023 · 6 min read
© 2022–2023 Johannes Matt
Link to $https://linkedin.com/in/johannes-mattLink to $https://github.com/jomatt