You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
449 lines
15 KiB
449 lines
15 KiB
= 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: |
|
|
|
[tabs] |
|
====== |
|
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 |
|
[tabs] |
|
====== |
|
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 |
|
[tabs] |
|
====== |
|
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: |
|
|
|
[tabs] |
|
====== |
|
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: |
|
|
|
[tabs] |
|
====== |
|
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.tenants.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`: |
|
|
|
[tabs] |
|
====== |
|
Java:: |
|
+ |
|
[source,java,role="primary"] |
|
---- |
|
@Bean |
|
JWTProcessor jwtProcessor(JWTClaimsSetAwareJWSKeySelector keySelector) { |
|
ConfigurableJWTProcessor<SecurityContext> jwtProcessor = |
|
new DefaultJWTProcessor(); |
|
jwtProcessor.setJWTClaimsSetAwareJWSKeySelector(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: |
|
|
|
[tabs] |
|
====== |
|
Java:: |
|
+ |
|
[source,java,role="primary"] |
|
---- |
|
@Component |
|
public class TenantJwtIssuerValidator implements OAuth2TokenValidator<Jwt> { |
|
private final TenantRepository tenants; |
|
|
|
private final OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.INVALID_TOKEN, "The iss claim is not valid", |
|
"https://tools.ietf.org/html/rfc6750#section-3.1"); |
|
|
|
public TenantJwtIssuerValidator(TenantRepository tenants) { |
|
this.tenants = tenants; |
|
} |
|
|
|
@Override |
|
public OAuth2TokenValidatorResult validate(Jwt token) { |
|
if(this.tenants.findById(token.getIssuer()) != null) { |
|
return OAuth2TokenValidatorResult.success(); |
|
} |
|
return OAuth2TokenValidatorResult.failure(this.error); |
|
} |
|
} |
|
---- |
|
|
|
Kotlin:: |
|
+ |
|
[source,kotlin,role="secondary"] |
|
---- |
|
@Component |
|
class TenantJwtIssuerValidator(private val tenants: TenantRepository) : OAuth2TokenValidator<Jwt> { |
|
private val error: OAuth2Error = OAuth2Error(OAuth2ErrorCodes.INVALID_TOKEN, "The iss claim is not valid", |
|
"https://tools.ietf.org/html/rfc6750#section-3.1") |
|
|
|
override fun validate(token: Jwt): OAuth2TokenValidatorResult { |
|
return if (tenants.findById(token.issuer) != null) |
|
OAuth2TokenValidatorResult.success() else OAuth2TokenValidatorResult.failure(error) |
|
} |
|
} |
|
---- |
|
====== |
|
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`]: |
|
|
|
[tabs] |
|
====== |
|
Java:: |
|
+ |
|
[source,java,role="primary"] |
|
---- |
|
@Bean |
|
JwtDecoder jwtDecoder(JWTProcessor jwtProcessor, OAuth2TokenValidator<Jwt> jwtValidator) { |
|
NimbusJwtDecoder decoder = new NimbusJwtDecoder(jwtProcessor); |
|
OAuth2TokenValidator<Jwt> validator = new DelegatingOAuth2TokenValidator<> |
|
(JwtValidators.createDefault(), 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].
|
|
|