mirror of
				https://github.com/spring-projects/spring-security.git
				synced 2025-11-04 00:28:54 +00:00 
			
		
		
		
	
		
			
				
	
	
		
			450 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			Plaintext
		
	
	
	
	
	
			
		
		
	
	
			450 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:
 | 
						|
 | 
						|
[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 = JwtIssuerAuthenticationManagerResolver
 | 
						|
    .fromTrustedIssuers("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
 | 
						|
    .fromTrustedIssuers("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.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:
 | 
						|
 | 
						|
[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].
 |