From 63647e95469998449ca6229bc30f4191dcb558b0 Mon Sep 17 00:00:00 2001 From: Josh Cummings Date: Mon, 4 Nov 2019 10:15:26 -0700 Subject: [PATCH] Add Resource Server Multi-tenancy Docs Fixes: gh-7532 --- .../servlet/oauth2/oauth2-resourceserver.adoc | 288 +++++++++++++++++- 1 file changed, 286 insertions(+), 2 deletions(-) diff --git a/docs/manual/src/docs/asciidoc/_includes/servlet/oauth2/oauth2-resourceserver.adoc b/docs/manual/src/docs/asciidoc/_includes/servlet/oauth2/oauth2-resourceserver.adoc index 2b7a029e6b..4a58d084ae 100644 --- a/docs/manual/src/docs/asciidoc/_includes/servlet/oauth2/oauth2-resourceserver.adoc +++ b/docs/manual/src/docs/asciidoc/_includes/servlet/oauth2/oauth2-resourceserver.adoc @@ -1148,8 +1148,292 @@ OpaqueTokenIntrospector introspector() { } ---- -Thus far we have only taken a look at the most basic authentication configuration. -Let's take a look at a few slightly more advanced options for configuring authentication. +[[oauth2reourceserver-opaqueandjwt]] +=== Supporting both JWT and Opaque Token + +In some cases, you may have a need to access both kinds of tokens. +For example, you may support more than one tenant where one tenant issues JWTs and the other issues opaque tokens. + +If this decision must be made at request-time, then you can use an `AuthenticationManagerResolver` to achieve it, like so: + +[source,java] +---- +@Bean +AuthenticationManagerResolver tokenAuthenticationManagerResolver() { + BearerTokenResolver bearerToken = new DefaultBearerTokenResolver(); + JwtAuthenticationProvider jwt = jwt(); + OpaqueTokenAuthenticationProvider opaqueToken = opaqueToken(); + + return request -> { + String token = bearerToken.resolve(request); + if (isAJwt(token)) { + return jwt::authenticate; + } else { + return opaqueToken::authenticate; + } + } +} +---- + +And then specify this `AuthenticationManagerResolver` in the DSL: + +[source,java] +---- +http + .authorizeRequests() + .anyRequest().authenticated() + .and() + .oauth2ResourceServer() + .authenticationManagerResolver(this.tokenAuthenticationManagerResolver); +---- + +[[oauth2resourceserver-multitenancy]] +=== Multi-tenancy + +A resource server is considered multi-tenant when there are multiple strategies for verifying a bearer token, keyed by some tenant identifier. + +For example, your resource server may accept bearer tokens from two different authorization servers. +Or, your authorization server may represent a multiplicity of issuers. + +In each case, there are two things that need to be done and trade-offs associated with how you choose to do them: + +1. Resolve the tenant +2. Propagate the tenant + +==== Resolving the Tenant By Request Material + +Resolving the tenant by request material can be done my implementing an `AuthenticationManagerResolver`, which determines the `AuthenticationManager` at runtime, like so: + +[source,java] +---- +@Component +public class TenantAuthenticationManagerResolver + implements AuthenticationManagerResolver { + private final BearerTokenResolver resolver = new DefaultBearerTokenResolver(); + private final TenantRepository tenants; <1> + + private final Map authenticationManagers = new ConcurrentHashMap<>(); <2> + + public TenantAuthenticationManagerResolver(TenantRepository tenants) { + this.tenants = tenants; + } + + @Override + public AuthenticationManager resolve(HttpServletRequest request) { + return this.authenticationManagers.computeIfAbsent(toTenant(request), this::fromTenant); + } + + private String toTenant(HttpServletRequest request) { + String[] pathParts = request.getRequestURI().split("/"); + return pathParts.length > 0 ? pathParts[1] : null; + } + + private AuthenticationManager fromTenant(String tenant) { + return Optional.ofNullable(this.tenants.get(tenant)) <3> + .map(JwtDecoders::fromIssuerLocation) <4> + .map(JwtAuthenticationProvider::new) + .orElseThrow(() -> new IllegalArgumentException("unknown tenant"))::authenticate; + } +} +---- +<1> A hypothetical source for tenant information +<2> A cache for `AuthenticationManager`s, keyed by tenant identifier +<3> Looking up the tenant is more secure than simply computing the issuer location on the fly - the lookup acts as a tenant whitelist +<4> Create a `JwtDecoder` via the discovery endpoint - the lazy lookup here means that you don't need to configure all tenants at startup + +And then specify this `AuthenticationManagerResolver` in the DSL: + +[source,java] +---- +http + .authorizeRequests() + .anyRequest().authenticated() + .and() + .oauth2ResourceServer() + .authenticationManagerResolver(this.tenantAuthenticationManagerResolver); +---- + +==== Resolving the Tenant By Claim + +Resolving the tenant by claim is similar to doing so by request material. +The only real difference is the `toTenant` method implementation: + +[source,java] +---- +@Component +public class TenantAuthenticationManagerResolver implements AuthenticationManagerResolver { + private final BearerTokenResolver resolver = new DefaultBearerTokenResolver(); + private final TenantRepository tenants; <1> + + private final Map authenticationManagers = new ConcurrentHashMap<>(); <2> + + public TenantAuthenticationManagerResolver(TenantRepository tenants) { + this.tenants = tenants; + } + + @Override + public AuthenticationManager resolve(HttpServletRequest request) { + return this.authenticationManagers.computeIfAbsent(toTenant(request), this::fromTenant); <3> + } + + private String toTenant(HttpServletRequest request) { + try { + String token = this.resolver.resolve(request); + return (String) JWTParser.parse(token).getJWTClaimsSet().getIssuer(); + } catch (Exception e) { + throw new IllegalArgumentException(e); + } + } + + private AuthenticationManager fromTenant(String tenant) { + return Optional.ofNullable(this.tenants.get(tenant)) <3> + .map(JwtDecoders::fromIssuerLocation) <4> + .map(JwtAuthenticationProvider::new) + .orElseThrow(() -> new IllegalArgumentException("unknown tenant"))::authenticate; + } +} +---- +<1> A hypothetical source for tenant information +<2> A cache for `AuthenticationManager`s, keyed by tenant identifier +<3> Looking up the tenant is more secure than simply computing the issuer location on the fly - the lookup acts as a tenant whitelist +<4> Create a `JwtDecoder` via the discovery endpoint - the lazy lookup here means that you don't need to configure all tenants at startup + +[source,java] +---- +http + .authorizeRequests() + .anyRequest().authenticated() + .and() + .oauth2ResourceServer() + .authenticationManagerResolver(this.tenantAuthenticationManagerResolver); +---- + +==== Parsing the Claim Only Once + +You may have observed that this strategy, while simple, comes with the trade-off that the JWT is parsed once by the `AuthenticationManagerResolver` and then again by the `JwtDecoder`. + +This extra parsing can be alleviated by configuring the `JwtDecoder` directly with a `JWTClaimSetAwareJWSKeySelector` from Nimbus: + +[source,java] +---- +@Component +public class TenantJWSKeySelector + implements JWTClaimSetAwareJWSKeySelector { + + private final TenantRepository tenants; <1> + private final Map> selectors = new ConcurrentHashMap<>(); <2> + + public TenantJWSKeySelector(TenantRepository tenants) { + this.tenants = tenants; + } + + @Override + public List selectKeys(JWSHeader jwsHeader, JWTClaimsSet jwtClaimsSet, SecurityContext securityContext) + throws KeySourceException { + return this.selectors.computeIfAbsent(toTenant(jwtClaimsSet), this::fromTenant) + .selectJWSKeys(jwsHeader, securityContext); + } + + private String toTenant(JWTClaimsSet claimSet) { + return (String) claimSet.getClaim("iss"); + } + + private JWSKeySelector fromTenant(String tenant) { + return Optional.ofNullable(this.tenantRepository.findById(tenant)) <3> + .map(t -> t.getAttrbute("jwks_uri")) + .map(this::fromUri) + .orElseThrow(() -> new IllegalArgumentException("unknown tenant")); + } + + private JWSKeySelector fromUri(String uri) { + try { + return JWSAlgorithmFamilyJWSKeySelector.fromJWKSetURL(new URL(uri)); <4> + } catch (Exception e) { + throw new IllegalArgumentException(e); + } + } +} +---- +<1> A hypothetical source for tenant information +<2> A cache for `JWKKeySelector`s, keyed by tenant identifier +<3> Looking up the tenant is more secure than simply calculating the JWK Set endpoint on the fly - the lookup acts as a tenant whitelist +<4> Create a `JWSKeySelector` via the types of keys that come back from the JWK Set endpoint - the lazy lookup here means that you don't need to configure all tenants at startup + +The above key selector is a composition of many key selectors. +It chooses which key selector to use based on the `iss` claim in the JWT. + +NOTE: To use this approach, make sure that the authorization server is configured to include the claim set as part of the token's signature. +Without this, you have no guarantee that the issuer hasn't been altered by a bad actor. + +Next, we can construct a `JWTProcessor`: + +[source,java] +---- +@Bean +JWTProcessor jwtProcessor(JWTClaimSetJWSKeySelector keySelector) { + ConfigurableJWTProcessor jwtProcessor = + new DefaultJWTProcessor(); + jwtProcessor.setJWTClaimSetJWSKeySelector(keySelector); + return jwtProcessor; +} +---- + +As you are already seeing, the trade-off for moving tenant-awareness down to this level is more configuration. +We have just a bit more. + +Next, we still want to make sure you are validating the issuer. +But, since the issuer may be different per JWT, then you'll need a tenant-aware validator, too: + +[source,java] +---- +@Component +public class TenantJwtIssuerValidator implements OAuth2TokenValidator { + private final TenantRepository tenants; + private final Map validators = new ConcurrentHashMap<>(); + + public TenantJwtIssuerValidator(TenantRepository tenants) { + this.tenants = tenants; + } + + @Override + public OAuth2TokenValidatorResult validate(Jwt token) { + return this.validators.computeIfAbsent(toTenant(token), this::fromTenant) + .validate(token); + } + + private String toTenant(Jwt jwt) { + return jwt.getIssuer(); + } + + private JwtIssuerValidator fromTenant(String tenant) { + return Optional.ofNullable(this.tenants.findById(tenant)) + .map(t -> t.getAttribute("issuer")) + .map(JwtIssuerValidator::new) + .orElseThrow(() -> new IllegalArgumentException("unknown tenant")); + } +} +---- + +Now that we have a tenant-aware processor and a tenant-aware validator, we can proceed with creating our `JwtDecoder`: + +[source,java] +---- +@Bean +JwtDecoder jwtDecoder(JWTProcessor jwtProcessor, OAuth2TokenValidator jwtValidator) { + NimbusJwtDecoder decoder = new NimbusJwtDecoder(processor); + OAuth2TokenValidator validator = new DelegatingOAuth2TokenValidator<> + (JwtValidators.createDefault(), this.jwtValidator); + decoder.setJwtValidator(validator); + return decoder; +} +---- + +We've finished talking about resolving the tenant. + +If you've chosen to resolve the tenant by request material, then you'll need to make sure you address your downstream resource servers in the same way. +For example, if you are resolving it by subdomain, you'll need to address the downstream resource server using the same subdomain. + +However, if you resolve it by a claim in the bearer token, read on to learn about <>. [[oauth2resourceserver-bearertoken-resolver]] === Bearer Token Resolution