parent
bd4f2057ca
commit
63647e9546
|
|
@ -1148,8 +1148,292 @@ OpaqueTokenIntrospector introspector() {
|
||||||
}
|
}
|
||||||
----
|
----
|
||||||
|
|
||||||
Thus far we have only taken a look at the most basic authentication configuration.
|
[[oauth2reourceserver-opaqueandjwt]]
|
||||||
Let's take a look at a few slightly more advanced options for configuring authentication.
|
=== 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<HttpServletRequest> 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<HttpServletRequest> {
|
||||||
|
private final BearerTokenResolver resolver = new DefaultBearerTokenResolver();
|
||||||
|
private final TenantRepository tenants; <1>
|
||||||
|
|
||||||
|
private final Map<String, AuthenticationManager> 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<HttpServletRequest> {
|
||||||
|
private final BearerTokenResolver resolver = new DefaultBearerTokenResolver();
|
||||||
|
private final TenantRepository tenants; <1>
|
||||||
|
|
||||||
|
private final Map<String, AuthenticationManager> 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<SecurityContext> {
|
||||||
|
|
||||||
|
private final TenantRepository tenants; <1>
|
||||||
|
private final Map<String, JWSKeySelector<SecurityContext>> selectors = new ConcurrentHashMap<>(); <2>
|
||||||
|
|
||||||
|
public TenantJWSKeySelector(TenantRepository tenants) {
|
||||||
|
this.tenants = tenants;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<? extends Key> 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<SecurityContext> 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<SecurityContext> 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<SecurityContext> 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<Jwt> {
|
||||||
|
private final TenantRepository tenants;
|
||||||
|
private final Map<String, JwtIssuerValidator> 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<Jwt> jwtValidator) {
|
||||||
|
NimbusJwtDecoder decoder = new NimbusJwtDecoder(processor);
|
||||||
|
OAuth2TokenValidator<Jwt> 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,Spring Security's support for bearer token propagation>>.
|
||||||
|
|
||||||
[[oauth2resourceserver-bearertoken-resolver]]
|
[[oauth2resourceserver-bearertoken-resolver]]
|
||||||
=== Bearer Token Resolution
|
=== Bearer Token Resolution
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue