mirror of
				https://github.com/spring-projects/spring-security.git
				synced 2025-10-27 12:48:44 +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].
 |