From de87675f6da8d2228f260b21eba33536d76678b7 Mon Sep 17 00:00:00 2001 From: Josh Cummings Date: Wed, 11 Dec 2019 17:53:56 -0700 Subject: [PATCH] Add JwtIssuerAuthenticationManagerResolver Fixes gh-7724 --- .../OAuth2ResourceServerConfigurerTests.java | 97 ++++++++- .../servlet/oauth2/oauth2-resourceserver.adoc | 117 +++-------- ...wtIssuerAuthenticationManagerResolver.java | 180 +++++++++++++++++ ...uerAuthenticationManagerResolverTests.java | 186 ++++++++++++++++++ 4 files changed, 492 insertions(+), 88 deletions(-) create mode 100644 oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/JwtIssuerAuthenticationManagerResolver.java create mode 100644 oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/authentication/JwtIssuerAuthenticationManagerResolverTests.java diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests.java index 8914c8e2604..5fdc5cf5ca6 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests.java @@ -28,10 +28,19 @@ 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.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.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 @@ public class OAuth2ResourceServerConfigurerTests { private static final String JWT_TOKEN = "token"; private static final String JWT_SUBJECT = "mock-test-subject"; - private static final Map JWT_CLAIMS = Collections.singletonMap(JwtClaimNames.SUB, JWT_SUBJECT); + private static final Map 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 void getAuthenticationManagerWhenConfiguredAuthenticationManagerThenTakes 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 @@ protected void configure(HttpSecurity http) throws Exception { } } + @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 @@ private static ResultMatcher insufficientScopeHeader() { ", 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 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) diff --git a/docs/manual/src/docs/asciidoc/_includes/servlet/oauth2/oauth2-resourceserver.adoc b/docs/manual/src/docs/asciidoc/_includes/servlet/oauth2/oauth2-resourceserver.adoc index fc9452a979a..03e331d63c8 100644 --- a/docs/manual/src/docs/asciidoc/_includes/servlet/oauth2/oauth2-resourceserver.adoc +++ b/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 { - private final BearerTokenResolver resolver = new DefaultBearerTokenResolver(); - private final TenantRepository tenants; <1> - - private final Map 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 { - private final BearerTokenResolver resolver = new DefaultBearerTokenResolver(); - private final TenantRepository tenants; <1> - - private final Map authenticationManagers = new ConcurrentHashMap<>(); <2> +private void addManager(Map 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 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 <>. diff --git a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/JwtIssuerAuthenticationManagerResolver.java b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/JwtIssuerAuthenticationManagerResolver.java new file mode 100644 index 00000000000..e64544bcf01 --- /dev/null +++ b/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 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 HttpServletRequest}'s + * Bearer Token. + * + * @author Josh Cummings + * @since 5.3 + */ +public final class JwtIssuerAuthenticationManagerResolver implements AuthenticationManagerResolver { + private static final OAuth2Error DEFAULT_INVALID_TOKEN = invalidToken("Invalid token"); + + private final AuthenticationManagerResolver issuerAuthenticationManagerResolver; + private final Converter 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 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: + *
+	 *     Map<String, AuthenticationManager> authenticationManagers = new HashMap<>();
+	 *     authenticationManagers.put("https://issuerOne.example.org", managerOne);
+	 *     authenticationManagers.put("https://issuerTwo.example.org", managerTwo);
+	 *     JwtAuthenticationManagerResolver resolver = new JwtAuthenticationManagerResolver
+	 *     	(authenticationManagers::get);
+	 * 
+ * + * The keys in the {@link Map} are the whitelist. + * + * @param issuerAuthenticationManagerResolver a strategy for resolving the {@link AuthenticationManager} by the issuer + */ + public JwtIssuerAuthenticationManagerResolver(AuthenticationManagerResolver 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 { + + 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 { + + private final Map authenticationManagers = new ConcurrentHashMap<>(); + private final Predicate trustedIssuer; + + TrustedIssuerJwtAuthenticationManagerResolver(Predicate 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; + } + } +} diff --git a/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/authentication/JwtIssuerAuthenticationManagerResolverTests.java b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/authentication/JwtIssuerAuthenticationManagerResolverTests.java new file mode 100644 index 00000000000..e4d215a3ca2 --- /dev/null +++ b/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 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(); + } +}