diff --git a/docs/manual/src/docs/asciidoc/_includes/reactive/oauth2/resource-server.adoc b/docs/manual/src/docs/asciidoc/_includes/reactive/oauth2/resource-server.adoc index 252aa13fba..576007453a 100644 --- a/docs/manual/src/docs/asciidoc/_includes/reactive/oauth2/resource-server.adoc +++ b/docs/manual/src/docs/asciidoc/_includes/reactive/oauth2/resource-server.adoc @@ -1005,6 +1005,77 @@ ReactiveOpaqueTokenIntrospector introspector() { } ---- +[[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 `JwtIssuerReactiveAuthenticationManagerResolver`, like so: + +[source,java] +---- +JwtIssuerReactiveAuthenticationManagerResolver authenticationManagerResolver = new JwtIssuerReactiveAuthenticationManagerResolver + ("https://idp.example.org/issuerOne", "https://idp.example.org/issuerTwo"); + +http + .authorizeRequests(authorize -> authorize + .anyRequest().authenticated() + ) + .oauth2ResourceServer(oauth2 -> oauth2 + .authenticationManagerResolver(authenticationManagerResolver) + ); +---- + +This is nice because the issuer endpoints are loaded lazily. +In fact, the corresponding `JwtReactiveAuthenticationManager` 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 `JwtIssuerReactiveAuthenticationManagerResolver` with a repository of `ReactiveAuthenticationManager` instances, which you can edit at runtime, like so: + +[source,java] +---- +private Mono addManager( + Map authenticationManagers, String issuer) { + + return Mono.fromCallable(() -> ReactiveJwtDecoders.fromIssuerLocation(issuer)) + .subscribeOn(Schedulers.boundedElastic()) + .map(JwtReactiveAuthenticationManager::new) + .doOnNext(authenticationManager -> authenticationManagers.put(issuer, authenticationManager)); +} + +// ... + +JwtIssuerReactiveAuthenticationManagerResolver authenticationManagerResolver = + new JwtIssuerReactiveAuthenticationManagerResolver(authenticationManagers::get); + +http + .authorizeRequests(authorize -> authorize + .anyRequest().authenticated() + ) + .oauth2ResourceServer(oauth2 -> oauth2 + .authenticationManagerResolver(authenticationManagerResolver) + ); +---- + +In this case, you construct `JwtIssuerReactiveAuthenticationManagerResolver` with a strategy for obtaining the `ReactiveAuthenticationManager` 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 `ReactiveAuthenticationManager` from it. +The issuer should be one that the code can verify from a trusted source like a whitelist. + == Bearer Token Propagation Now that you're in possession of a bearer token, it might be handy to pass that to downstream services. diff --git a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/JwtIssuerReactiveAuthenticationManagerResolver.java b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/JwtIssuerReactiveAuthenticationManagerResolver.java new file mode 100644 index 0000000000..40ff610edc --- /dev/null +++ b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/JwtIssuerReactiveAuthenticationManagerResolver.java @@ -0,0 +1,176 @@ +/* + * Copyright 2002-2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.oauth2.server.resource.authentication; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Predicate; + +import com.nimbusds.jwt.JWTParser; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +import org.springframework.core.convert.converter.Converter; +import org.springframework.lang.NonNull; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.ReactiveAuthenticationManager; +import org.springframework.security.authentication.ReactiveAuthenticationManagerResolver; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.jwt.ReactiveJwtDecoders; +import org.springframework.security.oauth2.server.resource.BearerTokenAuthenticationToken; +import org.springframework.security.oauth2.server.resource.InvalidBearerTokenException; +import org.springframework.security.oauth2.server.resource.web.server.ServerBearerTokenAuthenticationConverter; +import org.springframework.util.Assert; +import org.springframework.web.server.ServerWebExchange; + +/** + * An implementation of {@link ReactiveAuthenticationManagerResolver} that resolves a JWT-based + * {@link ReactiveAuthenticationManager} based on the + * Issuer in a + * signed JWT (JWS). + * + * To use, this class must be able to determine whether or not the `iss` claim is trusted. Recall that + * anyone can stand up an authorization server and issue valid tokens to a resource server. The simplest way + * to achieve this is to supply a whitelist of trusted issuers in the constructor. + * + * This class derives the Issuer from the `iss` claim found in the {@link ServerWebExchange}'s + * Bearer Token. + * + * @author Josh Cummings + * @since 5.3 + */ +public final class JwtIssuerReactiveAuthenticationManagerResolver + implements ReactiveAuthenticationManagerResolver { + + private final ReactiveAuthenticationManagerResolver issuerAuthenticationManagerResolver; + private final Converter> issuerConverter = new JwtClaimIssuerConverter(); + + /** + * Construct a {@link JwtIssuerReactiveAuthenticationManagerResolver} using the provided parameters + * + * @param trustedIssuers a whitelist of trusted issuers + */ + public JwtIssuerReactiveAuthenticationManagerResolver(String... trustedIssuers) { + this(Arrays.asList(trustedIssuers)); + } + + /** + * Construct a {@link JwtIssuerReactiveAuthenticationManagerResolver} using the provided parameters + * + * @param trustedIssuers a whitelist of trusted issuers + */ + public JwtIssuerReactiveAuthenticationManagerResolver(Collection trustedIssuers) { + Assert.notEmpty(trustedIssuers, "trustedIssuers cannot be empty"); + this.issuerAuthenticationManagerResolver = + new TrustedIssuerJwtAuthenticationManagerResolver + (Collections.unmodifiableCollection(trustedIssuers)::contains); + } + + /** + * Construct a {@link JwtIssuerReactiveAuthenticationManagerResolver} using the provided parameters + * + * Note that the {@link ReactiveAuthenticationManagerResolver} provided in this constructor will need to + * verify that the issuer is trusted. This should be done via a whitelist. + * + * One way to achieve this is with a {@link Map} where the keys are the known issuers: + *
+	 *     Map<String, ReactiveAuthenticationManager> authenticationManagers = new HashMap<>();
+	 *     authenticationManagers.put("https://issuerOne.example.org", managerOne);
+	 *     authenticationManagers.put("https://issuerTwo.example.org", managerTwo);
+	 *     JwtIssuerReactiveAuthenticationManagerResolver resolver = new JwtIssuerReactiveAuthenticationManagerResolver
+	 *     	(issuer -> Mono.justOrEmpty(authenticationManagers.get(issuer));
+	 * 
+ * + * The keys in the {@link Map} are the whitelist. + * + * @param issuerAuthenticationManagerResolver a strategy for resolving the {@link ReactiveAuthenticationManager} + * by the issuer + */ + public JwtIssuerReactiveAuthenticationManagerResolver + (ReactiveAuthenticationManagerResolver issuerAuthenticationManagerResolver) { + + Assert.notNull(issuerAuthenticationManagerResolver, "issuerAuthenticationManagerResolver cannot be null"); + this.issuerAuthenticationManagerResolver = issuerAuthenticationManagerResolver; + } + + /** + * Return an {@link AuthenticationManager} based off of the `iss` claim found in the request's bearer token + * + * @throws OAuth2AuthenticationException if the bearer token is malformed or an {@link ReactiveAuthenticationManager} + * can't be derived from the issuer + */ + @Override + public Mono resolve(ServerWebExchange exchange) { + return this.issuerConverter.convert(exchange) + .flatMap(issuer -> + this.issuerAuthenticationManagerResolver.resolve(issuer).switchIfEmpty( + Mono.error(new InvalidBearerTokenException("Invalid issuer " + issuer))) + ); + } + + private static class JwtClaimIssuerConverter + implements Converter> { + + private final ServerBearerTokenAuthenticationConverter converter = + new ServerBearerTokenAuthenticationConverter(); + + @Override + public Mono convert(@NonNull ServerWebExchange exchange) { + return this.converter.convert(exchange) + .cast(BearerTokenAuthenticationToken.class) + .flatMap(this::issuer); + } + + private Mono issuer(BearerTokenAuthenticationToken token) { + try { + String issuer = JWTParser.parse(token.getToken()).getJWTClaimsSet().getIssuer(); + return Mono.justOrEmpty(issuer).switchIfEmpty( + Mono.error(new InvalidBearerTokenException("Missing issuer"))); + } catch (Exception e) { + return Mono.error(new InvalidBearerTokenException(e.getMessage())); + } + } + } + + private static class TrustedIssuerJwtAuthenticationManagerResolver + implements ReactiveAuthenticationManagerResolver { + + private final Map> authenticationManagers = + new ConcurrentHashMap<>(); + private final Predicate trustedIssuer; + + TrustedIssuerJwtAuthenticationManagerResolver(Predicate trustedIssuer) { + this.trustedIssuer = trustedIssuer; + } + + @Override + public Mono resolve(String issuer) { + return Mono.just(issuer) + .filter(this.trustedIssuer) + .flatMap(iss -> + this.authenticationManagers.computeIfAbsent(iss, k -> + Mono.fromCallable(() -> ReactiveJwtDecoders.fromIssuerLocation(iss)) + .subscribeOn(Schedulers.boundedElastic()) + .map(JwtReactiveAuthenticationManager::new) + .cache()) + ); + } + } +} diff --git a/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/authentication/JwtIssuerReactiveAuthenticationManagerResolverTests.java b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/authentication/JwtIssuerReactiveAuthenticationManagerResolverTests.java new file mode 100644 index 0000000000..d694707096 --- /dev/null +++ b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/authentication/JwtIssuerReactiveAuthenticationManagerResolverTests.java @@ -0,0 +1,186 @@ +/* + * Copyright 2002-2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.oauth2.server.resource.authentication; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.JWSObject; +import com.nimbusds.jose.Payload; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.PlainJWT; +import net.minidev.json.JSONObject; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.Test; +import reactor.core.publisher.Mono; + +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.security.authentication.ReactiveAuthenticationManager; +import org.springframework.security.authentication.ReactiveAuthenticationManagerResolver; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.jose.TestKeys; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.Mockito.mock; +import static org.springframework.security.oauth2.jwt.JwtClaimNames.ISS; + +/** + * Tests for {@link JwtIssuerReactiveAuthenticationManagerResolver} + */ +public class JwtIssuerReactiveAuthenticationManagerResolverTests { + private static final String DEFAULT_RESPONSE_TEMPLATE = "{\n" + + " \"issuer\": \"%s\", \n" + + " \"jwks_uri\": \"%s/.well-known/jwks.json\" \n" + + "}"; + + private String jwt = jwt("iss", "trusted"); + private String evil = jwt("iss", "\""); + private String noIssuer = jwt("sub", "sub"); + + @Test + public void resolveWhenUsingTrustedIssuerThenReturnsAuthenticationManager() throws Exception { + try (MockWebServer server = new MockWebServer()) { + String issuer = server.url("").toString(); + server.enqueue(new MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody(String.format(DEFAULT_RESPONSE_TEMPLATE, issuer, issuer))); + JWSObject jws = new JWSObject(new JWSHeader(JWSAlgorithm.RS256), + new Payload(new JSONObject(Collections.singletonMap(ISS, issuer)))); + jws.sign(new RSASSASigner(TestKeys.DEFAULT_PRIVATE_KEY)); + + JwtIssuerReactiveAuthenticationManagerResolver authenticationManagerResolver = + new JwtIssuerReactiveAuthenticationManagerResolver(issuer); + MockServerWebExchange exchange = withBearerToken(jws.serialize()); + + ReactiveAuthenticationManager authenticationManager = + authenticationManagerResolver.resolve(exchange).block(); + assertThat(authenticationManager).isNotNull(); + + ReactiveAuthenticationManager cachedAuthenticationManager = + authenticationManagerResolver.resolve(exchange).block(); + assertThat(authenticationManager).isSameAs(cachedAuthenticationManager); + } + } + + @Test + public void resolveWhenUsingUntrustedIssuerThenException() { + JwtIssuerReactiveAuthenticationManagerResolver authenticationManagerResolver = + new JwtIssuerReactiveAuthenticationManagerResolver("other", "issuers"); + MockServerWebExchange exchange = withBearerToken(this.jwt); + + assertThatCode(() -> authenticationManagerResolver.resolve(exchange).block()) + .isInstanceOf(OAuth2AuthenticationException.class) + .hasMessageContaining("Invalid issuer"); + } + + @Test + public void resolveWhenUsingCustomIssuerAuthenticationManagerResolverThenUses() { + ReactiveAuthenticationManager authenticationManager = mock(ReactiveAuthenticationManager.class); + JwtIssuerReactiveAuthenticationManagerResolver authenticationManagerResolver = + new JwtIssuerReactiveAuthenticationManagerResolver(issuer -> Mono.just(authenticationManager)); + MockServerWebExchange exchange = withBearerToken(this.jwt); + + assertThat(authenticationManagerResolver.resolve(exchange).block()) + .isSameAs(authenticationManager); + } + + @Test + public void resolveWhenUsingExternalSourceThenRespondsToChanges() { + MockServerWebExchange exchange = withBearerToken(this.jwt); + + Map authenticationManagers = new HashMap<>(); + JwtIssuerReactiveAuthenticationManagerResolver authenticationManagerResolver = + new JwtIssuerReactiveAuthenticationManagerResolver(issuer -> Mono.justOrEmpty(authenticationManagers.get(issuer))); + assertThatCode(() -> authenticationManagerResolver.resolve(exchange).block()) + .isInstanceOf(OAuth2AuthenticationException.class) + .hasMessageContaining("Invalid issuer"); + + ReactiveAuthenticationManager authenticationManager = mock(ReactiveAuthenticationManager.class); + authenticationManagers.put("trusted", authenticationManager); + assertThat(authenticationManagerResolver.resolve(exchange).block()) + .isSameAs(authenticationManager); + + authenticationManagers.clear(); + assertThatCode(() -> authenticationManagerResolver.resolve(exchange).block()) + .isInstanceOf(OAuth2AuthenticationException.class) + .hasMessageContaining("Invalid issuer"); + } + + @Test + public void resolveWhenBearerTokenMalformedThenException() { + JwtIssuerReactiveAuthenticationManagerResolver authenticationManagerResolver = + new JwtIssuerReactiveAuthenticationManagerResolver("trusted"); + MockServerWebExchange exchange = withBearerToken("jwt"); + assertThatCode(() -> authenticationManagerResolver.resolve(exchange).block()) + .isInstanceOf(OAuth2AuthenticationException.class) + .hasMessageNotContaining("Invalid issuer"); + } + + @Test + public void resolveWhenBearerTokenNoIssuerThenException() { + JwtIssuerReactiveAuthenticationManagerResolver authenticationManagerResolver = + new JwtIssuerReactiveAuthenticationManagerResolver("trusted"); + MockServerWebExchange exchange = withBearerToken(this.noIssuer); + assertThatCode(() -> authenticationManagerResolver.resolve(exchange).block()) + .isInstanceOf(OAuth2AuthenticationException.class) + .hasMessageContaining("Missing issuer"); + } + + @Test + public void resolveWhenBearerTokenEvilThenGenericException() { + JwtIssuerReactiveAuthenticationManagerResolver authenticationManagerResolver = + new JwtIssuerReactiveAuthenticationManagerResolver("trusted"); + MockServerWebExchange exchange = withBearerToken(this.evil); + assertThatCode(() -> authenticationManagerResolver.resolve(exchange).block()) + .isInstanceOf(OAuth2AuthenticationException.class) + .hasMessage("Invalid token"); + } + + @Test + public void constructorWhenNullOrEmptyIssuersThenException() { + assertThatCode(() -> new JwtIssuerReactiveAuthenticationManagerResolver((Collection) null)) + .isInstanceOf(IllegalArgumentException.class); + assertThatCode(() -> new JwtIssuerReactiveAuthenticationManagerResolver(Collections.emptyList())) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void constructorWhenNullAuthenticationManagerResolverThenException() { + assertThatCode(() -> new JwtIssuerReactiveAuthenticationManagerResolver((ReactiveAuthenticationManagerResolver) null)) + .isInstanceOf(IllegalArgumentException.class); + } + + private String jwt(String claim, String value) { + PlainJWT jwt = new PlainJWT(new JWTClaimsSet.Builder().claim(claim, value).build()); + return jwt.serialize(); + } + + private MockServerWebExchange withBearerToken(String token) { + MockServerHttpRequest request = MockServerHttpRequest.get("/") + .header("Authorization", "Bearer " + token).build(); + return MockServerWebExchange.from(request); + } +}