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

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

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

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

A PlantUML version of below diagram can be found at Github

Fig 2. the authentication flow

The implementation

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

@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

This example can be extended in a few ways:

  • 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

Spring Security reference

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

MockWebServer

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

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store