123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445 |
- = OAuth 2.0 Resource Server Multitenancy
- [[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:
- .Multitenancy 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].
|