446 lines
15 KiB
Plaintext
446 lines
15 KiB
Plaintext
= OAuth 2.0 Resource Server Multi-tenancy
|
|
|
|
[[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:
|
|
|
|
====
|
|
.Java
|
|
[source,java,role="primary"]
|
|
----
|
|
@Bean
|
|
AuthenticationManagerResolver<HttpServletRequest> tokenAuthenticationManagerResolver
|
|
(JwtDecoder jwtDecoder, OpaqueTokenIntrospector opaqueTokenIntrospector) {
|
|
AuthenticationManager jwt = new ProviderManager(new JwtAuthenticationProvider(jwtDecoder));
|
|
AuthenticationManager opaqueToken = new ProviderManager(
|
|
new OpaqueTokenAuthenticationProvider(opaqueTokenIntrospector));
|
|
return (request) -> useJwt(request) ? jwt : opaqueToken;
|
|
}
|
|
----
|
|
|
|
.Kotlin
|
|
[source,kotlin,role="secondary"]
|
|
----
|
|
@Bean
|
|
fun tokenAuthenticationManagerResolver
|
|
(jwtDecoder: JwtDecoder, opaqueTokenIntrospector: OpaqueTokenIntrospector):
|
|
AuthenticationManagerResolver<HttpServletRequest> {
|
|
val jwt = ProviderManager(JwtAuthenticationProvider(jwtDecoder))
|
|
val opaqueToken = ProviderManager(OpaqueTokenAuthenticationProvider(opaqueTokenIntrospector));
|
|
|
|
return AuthenticationManagerResolver { request ->
|
|
if (useJwt(request)) {
|
|
jwt
|
|
} else {
|
|
opaqueToken
|
|
}
|
|
}
|
|
}
|
|
----
|
|
====
|
|
|
|
NOTE: The implementation of `useJwt(HttpServletRequest)` will likely depend on custom request material like the path.
|
|
|
|
And then specify this `AuthenticationManagerResolver` in the DSL:
|
|
|
|
.Authentication Manager Resolver
|
|
====
|
|
.Java
|
|
[source,java,role="primary"]
|
|
----
|
|
http
|
|
.authorizeHttpRequests(authorize -> authorize
|
|
.anyRequest().authenticated()
|
|
)
|
|
.oauth2ResourceServer(oauth2 -> oauth2
|
|
.authenticationManagerResolver(this.tokenAuthenticationManagerResolver)
|
|
);
|
|
----
|
|
|
|
.Kotlin
|
|
[source,kotlin,role="secondary"]
|
|
----
|
|
http {
|
|
authorizeRequests {
|
|
authorize(anyRequest, authenticated)
|
|
}
|
|
oauth2ResourceServer {
|
|
authenticationManagerResolver = tokenAuthenticationManagerResolver()
|
|
}
|
|
}
|
|
----
|
|
|
|
.Xml
|
|
[source,xml,role="secondary"]
|
|
----
|
|
<http>
|
|
<oauth2-resource-server authentication-manager-resolver-ref="tokenAuthenticationManagerResolver"/>
|
|
</http>
|
|
----
|
|
====
|
|
|
|
[[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 Claim
|
|
|
|
One way to differentiate tenants is by the issuer claim. Since the issuer claim accompanies signed JWTs, this can be done with the `JwtIssuerAuthenticationManagerResolver`, like so:
|
|
|
|
.Multi-tenancy Tenant by JWT Claim
|
|
====
|
|
.Java
|
|
[source,java,role="primary"]
|
|
----
|
|
JwtIssuerAuthenticationManagerResolver authenticationManagerResolver = new JwtIssuerAuthenticationManagerResolver
|
|
("https://idp.example.org/issuerOne", "https://idp.example.org/issuerTwo");
|
|
|
|
http
|
|
.authorizeHttpRequests(authorize -> authorize
|
|
.anyRequest().authenticated()
|
|
)
|
|
.oauth2ResourceServer(oauth2 -> oauth2
|
|
.authenticationManagerResolver(authenticationManagerResolver)
|
|
);
|
|
----
|
|
|
|
.Kotlin
|
|
[source,kotlin,role="secondary"]
|
|
----
|
|
val customAuthenticationManagerResolver = JwtIssuerAuthenticationManagerResolver
|
|
("https://idp.example.org/issuerOne", "https://idp.example.org/issuerTwo")
|
|
http {
|
|
authorizeRequests {
|
|
authorize(anyRequest, authenticated)
|
|
}
|
|
oauth2ResourceServer {
|
|
authenticationManagerResolver = customAuthenticationManagerResolver
|
|
}
|
|
}
|
|
----
|
|
|
|
.Xml
|
|
[source,xml,role="secondary"]
|
|
----
|
|
<http>
|
|
<oauth2-resource-server authentication-manager-resolver-ref="authenticationManagerResolver"/>
|
|
</http>
|
|
|
|
<bean id="authenticationManagerResolver"
|
|
class="org.springframework.security.oauth2.server.resource.authentication.JwtIssuerAuthenticationManagerResolver">
|
|
<constructor-arg>
|
|
<list>
|
|
<value>https://idp.example.org/issuerOne</value>
|
|
<value>https://idp.example.org/issuerTwo</value>
|
|
</list>
|
|
</constructor-arg>
|
|
</bean>
|
|
----
|
|
====
|
|
|
|
This is nice because the issuer endpoints are loaded lazily.
|
|
In fact, the corresponding `JwtAuthenticationProvider` is instantiated only when the first request with the corresponding issuer is sent.
|
|
This allows for an application startup that is independent from those authorization servers being up and available.
|
|
|
|
==== Dynamic Tenants
|
|
|
|
Of course, you may not want to restart the application each time a new tenant is added.
|
|
In this case, you can configure the `JwtIssuerAuthenticationManagerResolver` with a repository of `AuthenticationManager` instances, which you can edit at runtime, like so:
|
|
|
|
====
|
|
.Java
|
|
[source,java,role="primary"]
|
|
----
|
|
private void addManager(Map<String, AuthenticationManager> authenticationManagers, String issuer) {
|
|
JwtAuthenticationProvider authenticationProvider = new JwtAuthenticationProvider
|
|
(JwtDecoders.fromIssuerLocation(issuer));
|
|
authenticationManagers.put(issuer, authenticationProvider::authenticate);
|
|
}
|
|
|
|
// ...
|
|
|
|
JwtIssuerAuthenticationManagerResolver authenticationManagerResolver =
|
|
new JwtIssuerAuthenticationManagerResolver(authenticationManagers::get);
|
|
|
|
http
|
|
.authorizeHttpRequests(authorize -> authorize
|
|
.anyRequest().authenticated()
|
|
)
|
|
.oauth2ResourceServer(oauth2 -> oauth2
|
|
.authenticationManagerResolver(authenticationManagerResolver)
|
|
);
|
|
----
|
|
|
|
.Kotlin
|
|
[source,kotlin,role="secondary"]
|
|
----
|
|
private fun addManager(authenticationManagers: MutableMap<String, AuthenticationManager>, issuer: String) {
|
|
val authenticationProvider = JwtAuthenticationProvider(JwtDecoders.fromIssuerLocation(issuer))
|
|
authenticationManagers[issuer] = AuthenticationManager {
|
|
authentication: Authentication? -> authenticationProvider.authenticate(authentication)
|
|
}
|
|
}
|
|
|
|
// ...
|
|
|
|
val customAuthenticationManagerResolver: JwtIssuerAuthenticationManagerResolver =
|
|
JwtIssuerAuthenticationManagerResolver(authenticationManagers::get)
|
|
http {
|
|
authorizeRequests {
|
|
authorize(anyRequest, authenticated)
|
|
}
|
|
oauth2ResourceServer {
|
|
authenticationManagerResolver = customAuthenticationManagerResolver
|
|
}
|
|
}
|
|
----
|
|
====
|
|
|
|
In this case, you construct `JwtIssuerAuthenticationManagerResolver` with a strategy for obtaining the `AuthenticationManager` given the issuer.
|
|
This approach allows us to add and remove elements from the repository (shown as a `Map` in the snippet) at runtime.
|
|
|
|
NOTE: It would be unsafe to simply take any issuer and construct an `AuthenticationManager` from it.
|
|
The issuer should be one that the code can verify from a trusted source like a list of allowed issuers.
|
|
|
|
==== 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 xref:servlet/oauth2/resource-server/jwt.adoc#oauth2resourceserver-jwt-architecture-jwtdecoder[`JwtDecoder`] later on in the request.
|
|
|
|
This extra parsing can be alleviated by configuring the xref:servlet/oauth2/resource-server/jwt.adoc#oauth2resourceserver-jwt-architecture-jwtdecoder[`JwtDecoder`] directly with a `JWTClaimsSetAwareJWSKeySelector` from Nimbus:
|
|
|
|
====
|
|
.Java
|
|
[source,java,role="primary"]
|
|
----
|
|
@Component
|
|
public class TenantJWSKeySelector
|
|
implements JWTClaimsSetAwareJWSKeySelector<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 ex) {
|
|
throw new IllegalArgumentException(ex);
|
|
}
|
|
}
|
|
}
|
|
----
|
|
|
|
.Kotlin
|
|
[source,kotlin,role="secondary"]
|
|
----
|
|
@Component
|
|
class TenantJWSKeySelector(tenants: TenantRepository) : JWTClaimsSetAwareJWSKeySelector<SecurityContext> {
|
|
private val tenants: TenantRepository <1>
|
|
private val selectors: MutableMap<String, JWSKeySelector<SecurityContext>> = ConcurrentHashMap() <2>
|
|
|
|
init {
|
|
this.tenants = tenants
|
|
}
|
|
|
|
fun selectKeys(jwsHeader: JWSHeader?, jwtClaimsSet: JWTClaimsSet, securityContext: SecurityContext): List<Key?> {
|
|
return selectors.computeIfAbsent(toTenant(jwtClaimsSet)) { tenant: String -> fromTenant(tenant) }
|
|
.selectJWSKeys(jwsHeader, securityContext)
|
|
}
|
|
|
|
private fun toTenant(claimSet: JWTClaimsSet): String {
|
|
return claimSet.getClaim("iss") as String
|
|
}
|
|
|
|
private fun fromTenant(tenant: String): JWSKeySelector<SecurityContext> {
|
|
return Optional.ofNullable(this.tenants.findById(tenant)) <3>
|
|
.map { t -> t.getAttrbute("jwks_uri") }
|
|
.map { uri: String -> fromUri(uri) }
|
|
.orElseThrow { IllegalArgumentException("unknown tenant") }
|
|
}
|
|
|
|
private fun fromUri(uri: String): JWSKeySelector<SecurityContext?> {
|
|
return try {
|
|
JWSAlgorithmFamilyJWSKeySelector.fromJWKSetURL(URL(uri)) <4>
|
|
} catch (ex: Exception) {
|
|
throw IllegalArgumentException(ex)
|
|
}
|
|
}
|
|
}
|
|
----
|
|
====
|
|
<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 list of allowed tenants
|
|
<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`:
|
|
|
|
====
|
|
.Java
|
|
[source,java,role="primary"]
|
|
----
|
|
@Bean
|
|
JWTProcessor jwtProcessor(JWTClaimSetJWSKeySelector keySelector) {
|
|
ConfigurableJWTProcessor<SecurityContext> jwtProcessor =
|
|
new DefaultJWTProcessor();
|
|
jwtProcessor.setJWTClaimSetJWSKeySelector(keySelector);
|
|
return jwtProcessor;
|
|
}
|
|
----
|
|
|
|
.Kotlin
|
|
[source,kotlin,role="secondary"]
|
|
----
|
|
@Bean
|
|
fun jwtProcessor(keySelector: JWTClaimsSetAwareJWSKeySelector<SecurityContext>): JWTProcessor<SecurityContext> {
|
|
val jwtProcessor = DefaultJWTProcessor<SecurityContext>()
|
|
jwtProcessor.jwtClaimsSetAwareJWSKeySelector = 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:
|
|
|
|
====
|
|
.Java
|
|
[source,java,role="primary"]
|
|
----
|
|
@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"));
|
|
}
|
|
}
|
|
----
|
|
|
|
.Kotlin
|
|
[source,kotlin,role="secondary"]
|
|
----
|
|
@Component
|
|
class TenantJwtIssuerValidator(tenants: TenantRepository) : OAuth2TokenValidator<Jwt> {
|
|
private val tenants: TenantRepository
|
|
private val validators: MutableMap<String, JwtIssuerValidator> = ConcurrentHashMap()
|
|
override fun validate(token: Jwt): OAuth2TokenValidatorResult {
|
|
return validators.computeIfAbsent(toTenant(token)) { tenant: String -> fromTenant(tenant) }
|
|
.validate(token)
|
|
}
|
|
|
|
private fun toTenant(jwt: Jwt): String {
|
|
return jwt.issuer.toString()
|
|
}
|
|
|
|
private fun fromTenant(tenant: String): JwtIssuerValidator {
|
|
return Optional.ofNullable(tenants.findById(tenant))
|
|
.map({ t -> t.getAttribute("issuer") })
|
|
.map({ JwtIssuerValidator() })
|
|
.orElseThrow({ IllegalArgumentException("unknown tenant") })
|
|
}
|
|
|
|
init {
|
|
this.tenants = tenants
|
|
}
|
|
}
|
|
----
|
|
====
|
|
|
|
Now that we have a tenant-aware processor and a tenant-aware validator, we can proceed with creating our xref:servlet/oauth2/resource-server/jwt.adoc#oauth2resourceserver-jwt-architecture-jwtdecoder[`JwtDecoder`]:
|
|
|
|
====
|
|
.Java
|
|
[source,java,role="primary"]
|
|
----
|
|
@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;
|
|
}
|
|
----
|
|
|
|
.Kotlin
|
|
[source,kotlin,role="secondary"]
|
|
----
|
|
@Bean
|
|
fun jwtDecoder(jwtProcessor: JWTProcessor<SecurityContext>?, jwtValidator: OAuth2TokenValidator<Jwt>?): JwtDecoder {
|
|
val decoder = NimbusJwtDecoder(jwtProcessor)
|
|
val validator: OAuth2TokenValidator<Jwt> = DelegatingOAuth2TokenValidator(JwtValidators.createDefault(), jwtValidator)
|
|
decoder.setJwtValidator(validator)
|
|
return decoder
|
|
}
|
|
----
|
|
====
|
|
|
|
We've finished talking about resolving the tenant.
|
|
|
|
If you've chosen to resolve the tenant by something other than a JWT claim, 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 may 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 xref:servlet/oauth2/resource-server/bearer-tokens.adoc#oauth2resourceserver-bearertoken-resolver[Spring Security's support for bearer token propagation].
|