Support multiple IDP (tenant)s with Spring Security and customize it for security, performance and maintainability

Arthur Zhang
6 min readMar 21, 2021
Mt Footstool by Arthur

This tech blog explores how the Adatree engineering team leverages latest Spring Security features to support multiple identification providers and achieve security, performance and maintainability. We use a fictional story to explain the application security challenges that companies face and how we solved them.

A promising startup

You are building a subscription service. It is very popular and many consumers as well as business partners want to use it. To use your service, both consumers and business partners need login with credentials to identify themselves. After some investigation, you know that consumers prefer their Google Accounts or Facebook Login while your business partners prefer Auth0 or Okta (even though you know the former is now acquired by the latter). That means your service needs to be able to validate JWT tokens from four different authorization servers and the list may grow longer in future. Things certainly seem to be getting out of control!

Within this context, Google, Facebook, Auth0 and Okta are called Identification Providers, a.k.a IDPs. A user of your service obtains JWT tokens from their own IDP and then uses JWT tokens to consume your awesome SaaS. On the other side, your service is essentially a consumable resource on the internet, hence called a Resource Server. The capability for one resource server to support multiple IDPs / tenants is often called Resource Server Multi-tenancy.

It can be pretty hairy to support multi-tenancy in one service and it gets way worse when you throw extra validations, performance and maintainability (or sometimes called Clean Code) into the mix.

But do not worry, where there is a nail (problem), there is a hammer (solution). As engineers on the Java (Spring Boot) stack, we will solve this problem with the almighty Spring Security framework.

note: Spring Security 5.4.5 (Spring Boot 2.4.3) is needed to implement all the customisation mentioned in this blog.

A feasible sample application

After some considerations, I believe this solution is best served with a sample project. It will take too long if I implement the aforementioned story; a service with two endpoints that supports two IDPs should be able to demonstrate the design as well as those most desired customisations.

Here are the features and scopes for the sample application illustrated as below. It

  • Supports two pre-configured IDPs
  • Validates JWT claim ‘aud’ (audience) on top of standard JWT validation
  • Supports configuring JWKS caching to improve performance
  • Embraces design of Spring Security framework, i.e. plugging in isolated customisations into it to achieve optimal readability / maintainability
  • Drops the “SCOPE_” or “ROLE_” prefix from the converted authorities
  • Showcases how to test controllers and token validation respectively
Fig 1. greeting service that exposes both customer facing and machine-to-machine (m2m) endpoints

The greetings service has two groups of users:

  • Customers who interact with the services via Single Page Application (SPA) or a native mobile app. Those customers first login to a customer facing IDP to obtain tokens with scope “consumer:read:greetings”. These customers consume the service with consumer tokens.
  • Admin users or support engineers. They normally interact with management endpoints via Admin portal or CLI tooling using machine-to-machine tokens issued by different IDPs.

note: In this example, technically we are using multiple tenants (issuers) of the same IDP (Auth0). Pointing configuration of either admin or user to another IDP (such as Okta) will make this a true multiple-IDP solution that provides the maximum level of isolation between admin and user identifications. But IDPs are generally expensive and a combination of different audiences and JWKS provided by multiple tenants does provide a pretty solid isolation between partner oriented API endpoints and customer facing API endpoints.

The solution

Here is the authentication flow for a HttpServletRequest from 100 feet above. The light blue bubbles in the sequence diagram illustrate how to customize Spring Security at different points of the authentication process.

A PlantUML version of below diagram can be found at Github

Fig 2. the authentication flow

The implementation

Let’s have a close look at the configuration (a section in application.yaml) and then dive into code for each customisation point.

Configuration file:

demo.security.oauth2.resourceserver:
admin: ❶
issuer-uri: https://arthur-dev.au.auth0.com/
audience: https://arthur-dev.com.au/admin
jwk-cache-ttl: 30m
jwk-cache-refresh: 15m ❹
jwk-set-uri: https://arthur-dev.au.auth0.com/.well-known/jwks.json
user: ❶
issuer-uri: https://arthur-dev-alt.au.auth0.com/
audience: https://arthur-dev.com.au/user
jwk-cache-ttl: 30m
jwk-cache-refresh: 15m ❹
jwk-set-uri: https://arthur-dev-alt.au.auth0.com/.well-known/jwks.json

❶ this configuration supports one IDP for admins / m2m use cases and another IDP for consumers

❷ only accept configured issuers and use issuer as the key when lookup an IDP from an in memory repository

note: this can be easily extended with Spring JPA to use persistence storage like database for more sophisticated use cases

❸ the token must be issued with the right audience. Tokens from admin IDP must have https://arthur-dev.com.au/admin in the “aud” claim while those from user IDP must have https://arthur-dev.com.au/user.

❹ some IDPs are known to have latency issue with JWK set, so we want to configure the caching ttl and refresh frequency separately for IDPs

❺ these are the jwk-set-uris to configure JWK set caches with

Spring Security configuration with DSL

@Configuration
@EnableWebSecurity
class SecurityConfiguration extends WebSecurityConfigurerAdapter {

private JwtAuthenticationManagerIssuerResolver resolver;


@Override
protected void configure(HttpSecurity http) throws Exception {
http
...
.authorizeRequests(authz -> authz
.mvcMatchers(HttpMethod.GET, "/actuator/health").anonymous()
.mvcMatchers(HttpMethod.GET, "/").hasAnyAuthority("consumer:read:greetings", "admin:read:greetings")
.mvcMatchers(HttpMethod.POST, "/").hasAuthority("admin:write:greetings")
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.authenticationManagerResolver(resolver)
);
}
}

Implement an IDP lookup map using issuers as keys

@Override
public AuthenticationManager resolve(HttpServletRequest context) {
var issuer = issuerConverter.convert(context);
if (config.isTrustedIssuer(issuer)) {
return authenticationManagers.computeIfAbsent(
issuer,
(iss) -> {
return jwtAuthProvider(
config.getIdpConfigForIssuer(iss))::authenticate
;
});
} else {
throw new InvalidBearerTokenException(String.format("Untrusted issuer %s", issuer));
}
}

Configure Jwks cache, validators and authority conversion

JwtAuthenticationProvider jwtAuthProvider(OAuth2IdpConfig config) {
var jwtDecoder =
new NimbusJwtDecoder(configureJwksCache(config));
jwtDecoder.setJwtValidator(validators(config));
var authenticationProvider =
new JwtAuthenticationProvider(jwtDecoder);
authenticationProvider.setJwtAuthenticationConverter(
customJwtAuthenticationConverter());
return authenticationProvider;
}
  • JWK set caching
DefaultJWTProcessor configureJwksCache(OAuth2IdpConfig config) {
try {
var jwkSetCache =
new DefaultJWKSetCache(
config.jwkCacheTtl.toMinutes(),
config.jwkCacheRefresh.toMinutes(),
TimeUnit.MINUTES);
var jwsKeySelector =
JWSAlgorithmFamilyJWSKeySelector.fromJWKSource(
new RemoteJWKSet<>(
new URL(config.jwkSetUri), null, jwkSetCache)
);
var jwtProcessor = new DefaultJWTProcessor();
jwtProcessor.setJWSKeySelector(jwsKeySelector);
return jwtProcessor;
} catch (KeySourceException | MalformedURLException e) {
throw new IllegalArgumentException(e.getMessage());
}
}
  • Customise validators
private DelegatingOAuth2TokenValidator validators(OAuth2IdpConfig config) {
var audienceValidator =
new JwtClaimValidator<List<String>>(
AUD, aud -> config.audiences.stream().anyMatch(a -> aud.contains(a)));
var validateAudienceAndIssuer =
new DelegatingOAuth2TokenValidator(
JwtValidators.createDefaultWithIssuer(config.issuerUri),
audienceValidator)
;
return validateAudienceAndIssuer;
}
  • Customise parsed authorities
JwtAuthenticationConverter customJwtAuthenticationConverter() {
var jwtGrantedAuthoritiesConverter =
new JwtGrantedAuthoritiesConverter();
jwtGrantedAuthoritiesConverter
.setAuthorityPrefix(NO_PREFIX_FOR_AUTHORITIES);

var jwtAuthenticationConverter =
new JwtAuthenticationConverter();
jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(
jwtGrantedAuthoritiesConverter);

return jwtAuthenticationConverter;
}

Unit tests

  • Test security configuration is straightforward
@WebMvcTest(GreetingsController.class)
@Import(SecurityConfiguration.class)
@MockBean(JwtAuthenticationManagerIssuerResolver.class)
class GreetingsControllerTest {
@Autowired private MockMvc mockMvc;

@Test
@WithAnonymousUser
void denyAnonymous() throws Exception {
mockMvc.perform(get("/")).andExpect(status().isUnauthorized());
}

@Test
@WithMockUser(authorities = "consumer:read:greetings")
void greetWhenHasConsumerReadScope() throws Exception {
mockMvc.perform(get("/")).andExpect(status().isOk());
}
}
  • Properly testing of token validations involves a fair bit of yak shaving:
  1. serves JWK set uri, which I am using MockWebServer
  2. builds tokens with different combination of JWT claims and sign them using a JWK that is included in the JWK set
  3. triggers the authentication flow with tokens that are valid as well as invalid due to different reasons

Here is one sample to test that valid token works, please checkout the Github repo for more test cases.

@Test
void validToken() throws Exception {
var config =
CannedData.multipleIdps(
server.url("/").toString(), server.url("/notcalled").toString());
var resolver = new JwtAuthenticationManagerIssuerResolver(config);

var tokenBuilder = new TokenBuilder(config.user);
var jwt = tokenBuilder.build();

server.enqueue(
new MockResponse()
.setHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
.setBody(tokenBuilder.getJwks()));

var tokenString = bearTokenString(jwt);

when(request.getHeader(AUTHORIZATION)).thenReturn(tokenString);

var authManager = resolver.resolve(request);
org.springframework.security.core.Authentication authenticate =
authManager.authenticate(
new BearerTokenAuthenticationToken(buildAccessToken(jwt)));
assertTrue(authenticate.isAuthenticated());
assertEquals(
tokenBuilder.scopes,
authenticate.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList()));
}

Summary

Spring Security 5.4.5 (Spring Boot 2.4.3) has reached such a maturity level that engineers can implement the JWKS cache, extra validations and multi-tenancy fairly easily and keep the solution really neat. It is amazing.

This example can be extended in a few ways:

  • Use a persistence storage for IDPs
  • Allow dynamic registration and de-registration of IDPs, which is called Dynamic Client Registration in Consumer data right (CDR) a.k.a Open Banking
  • Add a configuration item to limit the scopes that a token from an issuer can carry

Resources

Github repository for the sample project

Spring Security reference

Spring Security Patterns (a great video by the Spring Security team)

MockWebServer

--

--

Arthur Zhang

Father of two giggly girls; a technical problem solver who focuses on both delivery and growth