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 );67http8 .authorizeHttpRequests(authz -> authz9 .anyRequest().authenticated()10 )11 .oauth2ResourceServer(oauth2 -> oauth212 .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 all3 * authentication managers. Each manager is responsible for resolving the authentication of a4 * specific tenant.5 *6 * @see AuthenticationManagerResolver7 */8@Component9public class MultiTenantAuthenticationManagerResolver implements AuthenticationManagerResolver<HttpServletRequest> {1011 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<>();1516 public MultiTenantAuthenticationManagerResolver(TenantService tenantService, JwtDecoder jwtDecoder) {17 this.tenantService = tenantService;18 this.jwtDecoder = jwtDecoder;19 }2021 @Override22 public AuthenticationManager resolve(HttpServletRequest request) {23 return authenticationManagers.computeIfAbsent(toTenant(request), this::fromTenant);24 }2526 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 }3435 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 }4243}
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 the3 * issuer of the provided JWT. The issuer needs to be well-known by the tenantService which4 * is responsible for checking the issuer against a whitelist of allowed tenants.5 *6 * @see JWTClaimsSetAwareJWSKeySelector7 */8@Component9public class MultiTenantJWSKeySelector implements JWTClaimsSetAwareJWSKeySelector<SecurityContext> {1011 private final TenantService tenantService;12 private final Map<String, JWSKeySelector<SecurityContext>> selectors = new ConcurrentHashMap<>();1314 public MultiTenantJWSKeySelector(TenantService tenantService) {15 this.tenantService = tenantService;16 }1718 @Override19 public List<? extends Key> selectKeys(JWSHeader jwsHeader,20 JWTClaimsSet jwtClaimsSet,21 SecurityContext securityContext) throws KeySourceException {2223 return selectors.computeIfAbsent(toTenant(jwtClaimsSet), this::fromTenant)24 .selectJWSKeys(jwsHeader, securityContext);25 }2627 private String toTenant(JWTClaimsSet claimSet) {28 return claimSet.getIssuer();29 }3031 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 }3738 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 }4546}
2. Using the defined key selector
Once we have defined the key selector, we can use it in a custom JWT processor.
1@Bean2JWTProcessor 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 whitelist3 * of allowed tenants. The allowed tenants are managed by the tenantService.4 *5 * @see OAuth2TokenValidator6 */7@Component8public class MultiTenantJwtIssuerValidator implements OAuth2TokenValidator<Jwt> {910 private final TenantService tenantService;11 private final Map<String, JwtIssuerValidator> validators = new ConcurrentHashMap<>();1213 public MultiTenantJwtIssuerValidator(TenantService tenantService) {14 this.tenantService = tenantService;15 }1617 @Override18 public OAuth2TokenValidatorResult validate(Jwt token) {19 return validators.computeIfAbsent(toTenant(token), this::fromTenant)20 .validate(token);21 }2223 private String toTenant(Jwt jwt) {24 return jwt.getIssuer().toString();25 }2627 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 }3334}
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@Bean2JwtDecoder 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
- Spring Security documentation: https://docs.spring.io/spring-security/reference/servlet/oauth2/resource-server/multitenancy.html
- Multi-tenant OAuth2 Spring Boot starter library: https://github.com/quantics-io/multitenant-oauth2-spring-boot-starter