Browse Source

Add JwtIssuerAuthenticationManagerResolver

Fixes gh-7724
Josh Cummings 5 năm trước cách đây
mục cha
commit
de87675f6d

+ 95 - 2
config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests.java

@@ -28,10 +28,19 @@ import java.time.Instant;
 import java.time.ZoneId;
 import java.util.Base64;
 import java.util.Collections;
+import java.util.HashMap;
 import java.util.Map;
 import java.util.stream.Collectors;
 import javax.annotation.PreDestroy;
 
+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.jose.jwk.JWKSet;
+import com.nimbusds.jose.jwk.RSAKey;
+import net.minidev.json.JSONObject;
 import okhttp3.mockwebserver.MockResponse;
 import okhttp3.mockwebserver.MockWebServer;
 import org.hamcrest.core.AllOf;
@@ -82,14 +91,15 @@ import org.springframework.security.oauth2.core.DefaultOAuth2AuthenticatedPrinci
 import org.springframework.security.oauth2.core.OAuth2Error;
 import org.springframework.security.oauth2.core.OAuth2TokenValidator;
 import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
+import org.springframework.security.oauth2.jose.TestKeys;
 import org.springframework.security.oauth2.jwt.Jwt;
-import org.springframework.security.oauth2.jwt.JwtClaimNames;
 import org.springframework.security.oauth2.jwt.JwtDecoder;
 import org.springframework.security.oauth2.jwt.JwtException;
 import org.springframework.security.oauth2.jwt.JwtTimestampValidator;
 import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
 import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthentication;
 import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
+import org.springframework.security.oauth2.server.resource.authentication.JwtIssuerAuthenticationManagerResolver;
 import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
 import org.springframework.security.oauth2.server.resource.introspection.NimbusOpaqueTokenIntrospector;
 import org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector;
@@ -127,6 +137,8 @@ import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 import static org.springframework.security.config.Customizer.withDefaults;
 import static org.springframework.security.oauth2.core.TestOAuth2AccessTokens.noScopes;
+import static org.springframework.security.oauth2.jwt.JwtClaimNames.ISS;
+import static org.springframework.security.oauth2.jwt.JwtClaimNames.SUB;
 import static org.springframework.security.oauth2.jwt.NimbusJwtDecoder.withJwkSetUri;
 import static org.springframework.security.oauth2.jwt.NimbusJwtDecoder.withPublicKey;
 import static org.springframework.security.oauth2.jwt.TestJwts.jwt;
@@ -149,7 +161,7 @@ import static org.springframework.web.bind.annotation.RequestMethod.POST;
 public class OAuth2ResourceServerConfigurerTests {
 	private static final String JWT_TOKEN = "token";
 	private static final String JWT_SUBJECT = "mock-test-subject";
-	private static final Map<String, Object> JWT_CLAIMS = Collections.singletonMap(JwtClaimNames.SUB, JWT_SUBJECT);
+	private static final Map<String, Object> JWT_CLAIMS = Collections.singletonMap(SUB, JWT_SUBJECT);
 	private static final Jwt JWT = jwt().build();
 	private static final String JWK_SET_URI = "https://mock.org";
 	private static final JwtAuthenticationToken JWT_AUTHENTICATION_TOKEN =
@@ -1332,6 +1344,50 @@ public class OAuth2ResourceServerConfigurerTests {
 		verify(http, never()).authenticationProvider(any(AuthenticationProvider.class));
 	}
 
+	// -- authentication manager resolver
+
+	@Test
+	public void getWhenMultipleIssuersThenUsesIssuerClaimToDifferentiate() throws Exception {
+		this.spring.register(WebServerConfig.class, MultipleIssuersConfig.class, BasicController.class).autowire();
+
+		MockWebServer server = this.spring.getContext().getBean(MockWebServer.class);
+		String metadata = "{\n"
+				+ "    \"issuer\": \"%s\", \n"
+				+ "    \"jwks_uri\": \"%s/.well-known/jwks.json\" \n"
+				+ "}";
+		String jwkSet = jwkSet();
+		String issuerOne = server.url("/issuerOne").toString();
+		String issuerTwo = server.url("/issuerTwo").toString();
+		String issuerThree = server.url("/issuerThree").toString();
+		String jwtOne = jwtFromIssuer(issuerOne);
+		String jwtTwo = jwtFromIssuer(issuerTwo);
+		String jwtThree = jwtFromIssuer(issuerThree);
+
+		mockWebServer(String.format(metadata, issuerOne, issuerOne));
+		mockWebServer(jwkSet);
+
+		this.mvc.perform(get("/authenticated")
+				.with(bearerToken(jwtOne)))
+				.andExpect(status().isOk())
+				.andExpect(content().string("test-subject"));
+
+		mockWebServer(String.format(metadata, issuerTwo, issuerTwo));
+		mockWebServer(jwkSet);
+
+		this.mvc.perform(get("/authenticated")
+				.with(bearerToken(jwtTwo)))
+				.andExpect(status().isOk())
+				.andExpect(content().string("test-subject"));
+
+		mockWebServer(String.format(metadata, issuerThree, issuerThree));
+		mockWebServer(jwkSet);
+
+		this.mvc.perform(get("/authenticated")
+				.with(bearerToken(jwtThree)))
+				.andExpect(status().isUnauthorized())
+				.andExpect(invalidTokenHeader("Invalid issuer"));
+	}
+
 	// -- Incorrect Configuration
 
 	@Test
@@ -2070,6 +2126,26 @@ public class OAuth2ResourceServerConfigurerTests {
 		}
 	}
 
+	@EnableWebSecurity
+	static class MultipleIssuersConfig extends WebSecurityConfigurerAdapter {
+		@Autowired
+		MockWebServer web;
+
+		@Override
+		protected void configure(HttpSecurity http) throws Exception {
+			String issuerOne = this.web.url("/issuerOne").toString();
+			String issuerTwo = this.web.url("/issuerTwo").toString();
+			JwtIssuerAuthenticationManagerResolver authenticationManagerResolver =
+					new JwtIssuerAuthenticationManagerResolver(issuerOne, issuerTwo);
+
+			// @formatter:off
+			http
+				.oauth2ResourceServer()
+					.authenticationManagerResolver(authenticationManagerResolver);
+			// @formatter:on
+		}
+	}
+
 	@EnableWebSecurity
 	static class AuthenticationManagerResolverPlusOtherConfig extends WebSecurityConfigurerAdapter {
 		@Override
@@ -2257,6 +2333,23 @@ public class OAuth2ResourceServerConfigurerTests {
 				", error_uri=\"https://tools.ietf.org/html/rfc6750#section-3.1\"");
 	}
 
+	private String jwkSet() {
+		return new JWKSet(new RSAKey.Builder(TestKeys.DEFAULT_PUBLIC_KEY)
+				.keyID("1").build()).toString();
+	}
+
+	private String jwtFromIssuer(String issuer) throws Exception {
+		Map<String, Object> claims = new HashMap<>();
+		claims.put(ISS, issuer);
+		claims.put(SUB, "test-subject");
+		claims.put("scope", "message:read");
+		JWSObject jws = new JWSObject(
+				new JWSHeader.Builder(JWSAlgorithm.RS256).keyID("1").build(),
+				new Payload(new JSONObject(claims)));
+		jws.sign(new RSASSASigner(TestKeys.DEFAULT_PRIVATE_KEY));
+		return jws.serialize();
+	}
+
 	private void mockWebServer(String response) {
 		this.web.enqueue(new MockResponse()
 				.setResponseCode(200)

+ 31 - 86
docs/manual/src/docs/asciidoc/_includes/servlet/oauth2/oauth2-resourceserver.adoc

@@ -1243,51 +1243,15 @@ In each case, there are two things that need to be done and trade-offs associate
 1. Resolve the tenant
 2. Propagate the tenant
 
-==== Resolving the Tenant By Request Material
+==== Resolving the Tenant By Claim
 
-Resolving the tenant by request material can be done my implementing an `AuthenticationManagerResolver`, which determines the `AuthenticationManager` at runtime, like so:
+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:
 
 [source,java]
 ----
-@Component
-public class TenantAuthenticationManagerResolver
-        implements AuthenticationManagerResolver<HttpServletRequest> {
-	private final BearerTokenResolver resolver = new DefaultBearerTokenResolver();
-	private final TenantRepository tenants; <1>
-
-	private final Map<String, AuthenticationManager> authenticationManagers = new ConcurrentHashMap<>(); <2>
-
-	public TenantAuthenticationManagerResolver(TenantRepository tenants) {
-		this.tenants = tenants;
-	}
-
-	@Override
-	public AuthenticationManager resolve(HttpServletRequest request) {
-		return this.authenticationManagers.computeIfAbsent(toTenant(request), this::fromTenant);
-	}
-
-	private String toTenant(HttpServletRequest request) {
-		String[] pathParts = request.getRequestURI().split("/");
-		return pathParts.length > 0 ? pathParts[1] : null;
-	}
+JwtIssuerAuthenticationManagerResolver authenticationManagerResolver = new JwtIssuerAuthenticationManagerResolver
+    ("https://idp.example.org/issuerOne", "https://idp.example.org/issuerTwo");
 
-	private AuthenticationManager fromTenant(String tenant) {
-		return Optional.ofNullable(this.tenants.get(tenant)) <3>
-				.map(JwtDecoders::fromIssuerLocation) <4>
-				.map(JwtAuthenticationProvider::new)
-				.orElseThrow(() -> new IllegalArgumentException("unknown tenant"))::authenticate;
-	}
-}
-----
-<1> A hypothetical source for tenant information
-<2> A cache for `AuthenticationManager`s, keyed by tenant identifier
-<3> Looking up the tenant is more secure than simply computing the issuer location on the fly - the lookup acts as a tenant whitelist
-<4> Create a `JwtDecoder` via the discovery endpoint - the lazy lookup here means that you don't need to configure all tenants at startup
-
-And then specify this `AuthenticationManagerResolver` in the DSL:
-
-[source,java]
-----
 http
     .authorizeRequests(authorizeRequests ->
         authorizeRequests
@@ -1295,57 +1259,32 @@ http
     )
     .oauth2ResourceServer(oauth2ResourceServer ->
         oauth2ResourceServer
-            .authenticationManagerResolver(this.tenantAuthenticationManagerResolver)
+            .authenticationManagerResolver(authenticationManagerResolver)
     );
 ----
 
-==== Resolving the Tenant By Claim
+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
 
-Resolving the tenant by claim is similar to doing so by request material.
-The only real difference is the `toTenant` method implementation:
+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:
 
 [source,java]
 ----
-@Component
-public class TenantAuthenticationManagerResolver implements AuthenticationManagerResolver<HttpServletRequest> {
-	private final BearerTokenResolver resolver = new DefaultBearerTokenResolver();
-	private final TenantRepository tenants; <1>
-
-	private final Map<String, AuthenticationManager> authenticationManagers = new ConcurrentHashMap<>(); <2>
+private void addManager(Map<String, AuthenticationManager> authenticationManagers, String issuer) {
+	JwtAuthenticationProvider authenticationProvider = new JwtAuthenticationProvider
+	        (JwtDecoders.fromIssuerLocation(issuer));
+	authenticationManagers.put(issuer, authenticationProvider::authenticate);
+}
 
-	public TenantAuthenticationManagerResolver(TenantRepository tenants) {
-		this.tenants = tenants;
-	}
+// ...
 
-	@Override
-	public AuthenticationManager resolve(HttpServletRequest request) {
-		return this.authenticationManagers.computeIfAbsent(toTenant(request), this::fromTenant); <3>
-	}
+JwtIssuerAuthenticationManagerResolver authenticationManagerResolver =
+        new JwtIssuerAuthenticationManagerResolver(authenticationManagers::get);
 
-	private String toTenant(HttpServletRequest request) {
-		try {
-			String token = this.resolver.resolve(request);
-			return (String) JWTParser.parse(token).getJWTClaimsSet().getIssuer();
-		} catch (Exception e) {
-			throw new IllegalArgumentException(e);
-		}
-	}
-
-	private AuthenticationManager fromTenant(String tenant) {
-		return Optional.ofNullable(this.tenants.get(tenant)) <3>
-				.map(JwtDecoders::fromIssuerLocation) <4>
-				.map(JwtAuthenticationProvider::new)
-				.orElseThrow(() -> new IllegalArgumentException("unknown tenant"))::authenticate;
-	}
-}
-----
-<1> A hypothetical source for tenant information
-<2> A cache for `AuthenticationManager`s, keyed by tenant identifier
-<3> Looking up the tenant is more secure than simply computing the issuer location on the fly - the lookup acts as a tenant whitelist
-<4> Create a `JwtDecoder` via the discovery endpoint - the lazy lookup here means that you don't need to configure all tenants at startup
-
-[source,java]
-----
 http
     .authorizeRequests(authorizeRequests ->
         authorizeRequests
@@ -1353,13 +1292,19 @@ http
     )
     .oauth2ResourceServer(oauth2ResourceServer ->
         oauth2ResourceServer
-            .authenticationManagerResolver(this.tenantAuthenticationManagerResolver)
+            .authenticationManagerResolver(authenticationManagerResolver)
     );
 ----
 
-==== Parsing the Claim Only Once
+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 whitelist.
+
+===== 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 `JwtDecoder`.
+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 `JwtDecoder` later on in the request.
 
 This extra parsing can be alleviated by configuring the `JwtDecoder` directly with a `JWTClaimSetAwareJWSKeySelector` from Nimbus:
 
@@ -1479,8 +1424,8 @@ JwtDecoder jwtDecoder(JWTProcessor jwtProcessor, OAuth2TokenValidator<Jwt> jwtVa
 
 We've finished talking about resolving the tenant.
 
-If you've chosen to resolve the tenant by request material, 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'll need to address the downstream resource server using the same subdomain.
+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 <<oauth2resourceserver-bearertoken-resolver,Spring Security's support for bearer token propagation>>.
 

+ 180 - 0
oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/JwtIssuerAuthenticationManagerResolver.java

@@ -0,0 +1,180 @@
+/*
+ * Copyright 2002-2019 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 javax.servlet.http.HttpServletRequest;
+
+import com.nimbusds.jwt.JWTParser;
+
+import org.springframework.core.convert.converter.Converter;
+import org.springframework.http.HttpStatus;
+import org.springframework.lang.NonNull;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.authentication.AuthenticationManagerResolver;
+import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.core.OAuth2Error;
+import org.springframework.security.oauth2.jwt.JwtDecoder;
+import org.springframework.security.oauth2.jwt.JwtDecoders;
+import org.springframework.security.oauth2.server.resource.BearerTokenError;
+import org.springframework.security.oauth2.server.resource.BearerTokenErrorCodes;
+import org.springframework.security.oauth2.server.resource.web.BearerTokenResolver;
+import org.springframework.security.oauth2.server.resource.web.DefaultBearerTokenResolver;
+import org.springframework.util.Assert;
+
+/**
+ * An implementation of {@link AuthenticationManagerResolver} that resolves a JWT-based {@link AuthenticationManager}
+ * based on the <a href="https://openid.net/specs/openid-connect-core-1_0.html#IssuerIdentifier">Issuer</a> 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 HttpServletRequest}'s
+ * <a href="https://tools.ietf.org/html/rfc6750#section-1.2" target="_blank">Bearer Token</a>.
+ *
+ * @author Josh Cummings
+ * @since 5.3
+ */
+public final class JwtIssuerAuthenticationManagerResolver implements AuthenticationManagerResolver<HttpServletRequest> {
+	private static final OAuth2Error DEFAULT_INVALID_TOKEN = invalidToken("Invalid token");
+
+	private final AuthenticationManagerResolver<String> issuerAuthenticationManagerResolver;
+	private final Converter<HttpServletRequest, String> issuerConverter = new JwtClaimIssuerConverter();
+
+	/**
+	 * Construct a {@link JwtIssuerAuthenticationManagerResolver} using the provided parameters
+	 *
+	 * @param trustedIssuers a whitelist of trusted issuers
+	 */
+	public JwtIssuerAuthenticationManagerResolver(String... trustedIssuers) {
+		this(Arrays.asList(trustedIssuers));
+	}
+
+	/**
+	 * Construct a {@link JwtIssuerAuthenticationManagerResolver} using the provided parameters
+	 *
+	 * @param trustedIssuers a whitelist of trusted issuers
+	 */
+	public JwtIssuerAuthenticationManagerResolver(Collection<String> trustedIssuers) {
+		Assert.notEmpty(trustedIssuers, "trustedIssuers cannot be empty");
+		this.issuerAuthenticationManagerResolver =
+				new TrustedIssuerJwtAuthenticationManagerResolver
+						(Collections.unmodifiableCollection(trustedIssuers)::contains);
+	}
+
+	/**
+	 * Construct a {@link JwtIssuerAuthenticationManagerResolver} using the provided parameters
+	 *
+	 * Note that the {@link AuthenticationManagerResolver} 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:
+	 * <pre>
+	 *     Map&lt;String, AuthenticationManager&gt; authenticationManagers = new HashMap&lt;&gt;();
+	 *     authenticationManagers.put("https://issuerOne.example.org", managerOne);
+	 *     authenticationManagers.put("https://issuerTwo.example.org", managerTwo);
+	 *     JwtAuthenticationManagerResolver resolver = new JwtAuthenticationManagerResolver
+	 *     	(authenticationManagers::get);
+	 * </pre>
+	 *
+	 * The keys in the {@link Map} are the whitelist.
+	 *
+	 * @param issuerAuthenticationManagerResolver a strategy for resolving the {@link AuthenticationManager} by the issuer
+	 */
+	public JwtIssuerAuthenticationManagerResolver(AuthenticationManagerResolver<String> 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 AuthenticationManager}
+	 * can't be derived from the issuer
+	 */
+	@Override
+	public AuthenticationManager resolve(HttpServletRequest request) {
+		String issuer = this.issuerConverter.convert(request);
+		AuthenticationManager authenticationManager = this.issuerAuthenticationManagerResolver.resolve(issuer);
+		if (authenticationManager == null) {
+			throw new OAuth2AuthenticationException(invalidToken("Invalid issuer " + issuer));
+		}
+		return authenticationManager;
+	}
+
+	private static class JwtClaimIssuerConverter
+			implements Converter<HttpServletRequest, String> {
+
+		private final BearerTokenResolver resolver = new DefaultBearerTokenResolver();
+
+		@Override
+		public String convert(@NonNull HttpServletRequest request) {
+			String token = this.resolver.resolve(request);
+			try {
+				String issuer = JWTParser.parse(token).getJWTClaimsSet().getIssuer();
+				if (issuer != null) {
+					return issuer;
+				}
+			} catch (Exception e) {
+				throw new OAuth2AuthenticationException(invalidToken(e.getMessage()));
+			}
+			throw new OAuth2AuthenticationException(invalidToken("Missing issuer"));
+		}
+	}
+
+	private static class TrustedIssuerJwtAuthenticationManagerResolver
+			implements AuthenticationManagerResolver<String> {
+
+		private final Map<String, AuthenticationManager> authenticationManagers = new ConcurrentHashMap<>();
+		private final Predicate<String> trustedIssuer;
+
+		TrustedIssuerJwtAuthenticationManagerResolver(Predicate<String> trustedIssuer) {
+			this.trustedIssuer = trustedIssuer;
+		}
+
+		@Override
+		public AuthenticationManager resolve(String issuer) {
+			if (this.trustedIssuer.test(issuer)) {
+				return this.authenticationManagers.computeIfAbsent(issuer, k -> {
+					JwtDecoder jwtDecoder = JwtDecoders.fromIssuerLocation(issuer);
+					return new JwtAuthenticationProvider(jwtDecoder)::authenticate;
+				});
+			}
+			return null;
+		}
+	}
+
+	private static OAuth2Error invalidToken(String message) {
+		try {
+			return new BearerTokenError(
+				BearerTokenErrorCodes.INVALID_TOKEN,
+				HttpStatus.UNAUTHORIZED,
+				message,
+				"https://tools.ietf.org/html/rfc6750#section-3.1");
+		} catch (IllegalArgumentException malformed) {
+			// some third-party library error messages are not suitable for RFC 6750's error message charset
+			return DEFAULT_INVALID_TOKEN;
+		}
+	}
+}

+ 186 - 0
oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/authentication/JwtIssuerAuthenticationManagerResolverTests.java

@@ -0,0 +1,186 @@
+/*
+ * Copyright 2002-2019 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 org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.authentication.AuthenticationManagerResolver;
+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 JwtIssuerAuthenticationManagerResolver}
+ */
+public class JwtIssuerAuthenticationManagerResolverTests {
+	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()) {
+			server.start();
+			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));
+
+			JwtIssuerAuthenticationManagerResolver authenticationManagerResolver =
+					new JwtIssuerAuthenticationManagerResolver(issuer);
+			MockHttpServletRequest request = new MockHttpServletRequest();
+			request.addHeader("Authorization", "Bearer " + jws.serialize());
+
+			AuthenticationManager authenticationManager =
+					authenticationManagerResolver.resolve(request);
+			assertThat(authenticationManager).isNotNull();
+
+			AuthenticationManager cachedAuthenticationManager =
+					authenticationManagerResolver.resolve(request);
+			assertThat(authenticationManager).isSameAs(cachedAuthenticationManager);
+		}
+	}
+
+	@Test
+	public void resolveWhenUsingUntrustedIssuerThenException() {
+		JwtIssuerAuthenticationManagerResolver authenticationManagerResolver =
+				new JwtIssuerAuthenticationManagerResolver("other", "issuers");
+		MockHttpServletRequest request = new MockHttpServletRequest();
+		request.addHeader("Authorization", "Bearer " + this.jwt);
+
+		assertThatCode(() -> authenticationManagerResolver.resolve(request))
+				.isInstanceOf(OAuth2AuthenticationException.class)
+				.hasMessageContaining("Invalid issuer");
+	}
+
+	@Test
+	public void resolveWhenUsingCustomIssuerAuthenticationManagerResolverThenUses() {
+		AuthenticationManager authenticationManager = mock(AuthenticationManager.class);
+		JwtIssuerAuthenticationManagerResolver authenticationManagerResolver =
+				new JwtIssuerAuthenticationManagerResolver(issuer -> authenticationManager);
+		MockHttpServletRequest request = new MockHttpServletRequest();
+		request.addHeader("Authorization", "Bearer " + this.jwt);
+
+		assertThat(authenticationManagerResolver.resolve(request))
+				.isSameAs(authenticationManager);
+	}
+
+	@Test
+	public void resolveWhenUsingExternalSourceThenRespondsToChanges() {
+		MockHttpServletRequest request = new MockHttpServletRequest();
+		request.addHeader("Authorization", "Bearer " + this.jwt);
+
+		Map<String, AuthenticationManager> authenticationManagers = new HashMap<>();
+		JwtIssuerAuthenticationManagerResolver authenticationManagerResolver =
+				new JwtIssuerAuthenticationManagerResolver(authenticationManagers::get);
+		assertThatCode(() -> authenticationManagerResolver.resolve(request))
+				.isInstanceOf(OAuth2AuthenticationException.class)
+				.hasMessageContaining("Invalid issuer");
+
+		AuthenticationManager authenticationManager = mock(AuthenticationManager.class);
+		authenticationManagers.put("trusted", authenticationManager);
+		assertThat(authenticationManagerResolver.resolve(request))
+				.isSameAs(authenticationManager);
+
+		authenticationManagers.clear();
+		assertThatCode(() -> authenticationManagerResolver.resolve(request))
+				.isInstanceOf(OAuth2AuthenticationException.class)
+				.hasMessageContaining("Invalid issuer");
+	}
+
+	@Test
+	public void resolveWhenBearerTokenMalformedThenException() {
+		JwtIssuerAuthenticationManagerResolver authenticationManagerResolver =
+				new JwtIssuerAuthenticationManagerResolver("trusted");
+		MockHttpServletRequest request = new MockHttpServletRequest();
+		request.addHeader("Authorization", "Bearer jwt");
+		assertThatCode(() -> authenticationManagerResolver.resolve(request))
+				.isInstanceOf(OAuth2AuthenticationException.class)
+				.hasMessageNotContaining("Invalid issuer");
+	}
+
+	@Test
+	public void resolveWhenBearerTokenNoIssuerThenException() {
+		JwtIssuerAuthenticationManagerResolver authenticationManagerResolver =
+				new JwtIssuerAuthenticationManagerResolver("trusted");
+		MockHttpServletRequest request = new MockHttpServletRequest();
+		request.addHeader("Authorization", "Bearer " + this.noIssuer);
+		assertThatCode(() -> authenticationManagerResolver.resolve(request))
+				.isInstanceOf(OAuth2AuthenticationException.class)
+				.hasMessageContaining("Missing issuer");
+	}
+
+	@Test
+	public void resolveWhenBearerTokenEvilThenGenericException() {
+		JwtIssuerAuthenticationManagerResolver authenticationManagerResolver =
+				new JwtIssuerAuthenticationManagerResolver("trusted");
+		MockHttpServletRequest request = new MockHttpServletRequest();
+		request.addHeader("Authorization", "Bearer " + this.evil);
+		assertThatCode(() -> authenticationManagerResolver.resolve(request))
+				.isInstanceOf(OAuth2AuthenticationException.class)
+				.hasMessage("Invalid token");
+	}
+
+	@Test
+	public void constructorWhenNullOrEmptyIssuersThenException() {
+		assertThatCode(() -> new JwtIssuerAuthenticationManagerResolver((Collection) null))
+				.isInstanceOf(IllegalArgumentException.class);
+		assertThatCode(() -> new JwtIssuerAuthenticationManagerResolver(Collections.emptyList()))
+				.isInstanceOf(IllegalArgumentException.class);
+	}
+
+	@Test
+	public void constructorWhenNullAuthenticationManagerResolverThenException() {
+		assertThatCode(() -> new JwtIssuerAuthenticationManagerResolver((AuthenticationManagerResolver) 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();
+	}
+}