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 a7d00de5d05..a1dbcfff029 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 @@ -39,6 +39,7 @@ import com.nimbusds.jose.crypto.RSASSASigner; import com.nimbusds.jose.jwk.JWKSet; import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.util.JSONObjectUtils; import jakarta.annotation.PreDestroy; import jakarta.servlet.http.HttpServletRequest; import net.minidev.json.JSONObject; @@ -62,6 +63,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.support.GenericApplicationContext; +import org.springframework.core.ParameterizedTypeReference; import org.springframework.core.convert.converter.Converter; import org.springframework.core.env.ConfigurableEnvironment; import org.springframework.core.env.Environment; @@ -121,9 +123,9 @@ import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; import org.springframework.security.oauth2.server.resource.authentication.JwtIssuerAuthenticationManagerResolver; -import org.springframework.security.oauth2.server.resource.introspection.NimbusOpaqueTokenIntrospector; import org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenAuthenticationConverter; import org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector; +import org.springframework.security.oauth2.server.resource.introspection.SpringOpaqueTokenIntrospector; import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint; import org.springframework.security.oauth2.server.resource.web.BearerTokenResolver; import org.springframework.security.oauth2.server.resource.web.DefaultBearerTokenResolver; @@ -217,7 +219,7 @@ public class OAuth2ResourceServerConfigurerTests { @Test public void getWhenUsingDefaultsWithValidBearerTokenThenAcceptsRequest() throws Exception { this.spring.register(RestOperationsConfig.class, DefaultConfig.class, BasicController.class).autowire(); - mockRestOperations(jwks("Default")); + mockJwksRestOperations(jwks("Default")); String token = this.token("ValidNoScopes"); // @formatter:off this.mvc.perform(get("/").with(bearerToken(token))) @@ -232,7 +234,7 @@ public void getWhenCustomSecurityContextHolderStrategyThenUses() throws Exceptio .register(RestOperationsConfig.class, DefaultConfig.class, BasicController.class, SecurityContextChangedListenerConfig.class) .autowire(); - mockRestOperations(jwks("Default")); + mockJwksRestOperations(jwks("Default")); String token = this.token("ValidNoScopes"); // @formatter:off this.mvc.perform(get("/").with(bearerToken(token))) @@ -248,7 +250,7 @@ public void getWhenSecurityContextHolderStrategyThenUses() throws Exception { .register(RestOperationsConfig.class, DefaultConfig.class, SecurityContextChangedListenerConfig.class, BasicController.class) .autowire(); - mockRestOperations(jwks("Default")); + mockJwksRestOperations(jwks("Default")); String token = this.token("ValidNoScopes"); // @formatter:off this.mvc.perform(get("/").with(bearerToken(token))) @@ -261,7 +263,7 @@ public void getWhenSecurityContextHolderStrategyThenUses() throws Exception { @Test public void getWhenUsingDefaultsInLambdaWithValidBearerTokenThenAcceptsRequest() throws Exception { this.spring.register(RestOperationsConfig.class, DefaultInLambdaConfig.class, BasicController.class).autowire(); - mockRestOperations(jwks("Default")); + mockJwksRestOperations(jwks("Default")); String token = this.token("ValidNoScopes"); // @formatter:off this.mvc.perform(get("/").with(bearerToken(token))) @@ -297,7 +299,7 @@ public void getWhenUsingJwkSetUriInLambdaThenAcceptsRequest() throws Exception { @Test public void getWhenUsingDefaultsWithExpiredBearerTokenThenInvalidToken() throws Exception { this.spring.register(RestOperationsConfig.class, DefaultConfig.class, BasicController.class).autowire(); - mockRestOperations(jwks("Default")); + mockJwksRestOperations(jwks("Default")); String token = this.token("Expired"); // @formatter:off this.mvc.perform(get("/").with(bearerToken(token))) @@ -341,7 +343,7 @@ public void getWhenUsingDefaultsWithMalformedBearerTokenThenInvalidToken() throw @Test public void getWhenUsingDefaultsWithMalformedPayloadThenInvalidToken() throws Exception { this.spring.register(RestOperationsConfig.class, DefaultConfig.class).autowire(); - mockRestOperations(jwks("Default")); + mockJwksRestOperations(jwks("Default")); String token = this.token("MalformedPayload"); // @formatter:off this.mvc.perform(get("/").with(bearerToken(token))) @@ -364,7 +366,7 @@ public void getWhenUsingDefaultsWithUnsignedBearerTokenThenInvalidToken() throws @Test public void getWhenUsingDefaultsWithBearerTokenBeforeNotBeforeThenInvalidToken() throws Exception { this.spring.register(RestOperationsConfig.class, DefaultConfig.class).autowire(); - this.mockRestOperations(jwks("Default")); + this.mockJwksRestOperations(jwks("Default")); String token = this.token("TooEarly"); // @formatter:off this.mvc.perform(get("/").with(bearerToken(token))) @@ -421,7 +423,7 @@ public void postWhenCsrfDisabledWithBearerTokenAsFormParameterThenIgnoresToken() @Test public void getWhenAnonymousDisabledThenAllows() throws Exception { this.spring.register(RestOperationsConfig.class, AnonymousDisabledConfig.class).autowire(); - mockRestOperations(jwks("Default")); + mockJwksRestOperations(jwks("Default")); String token = token("ValidNoScopes"); // @formatter:off this.mvc.perform(get("/authenticated").with(bearerToken(token))) @@ -442,7 +444,7 @@ public void getWhenUsingDefaultsWithNoBearerTokenThenUnauthorized() throws Excep @Test public void getWhenUsingDefaultsWithSufficientlyScopedBearerTokenThenAcceptsRequest() throws Exception { this.spring.register(RestOperationsConfig.class, DefaultConfig.class, BasicController.class).autowire(); - mockRestOperations(jwks("Default")); + mockJwksRestOperations(jwks("Default")); String token = this.token("ValidMessageReadScope"); // @formatter:off this.mvc.perform(get("/requires-read-scope").with(bearerToken(token))) @@ -454,7 +456,7 @@ public void getWhenUsingDefaultsWithSufficientlyScopedBearerTokenThenAcceptsRequ @Test public void getWhenUsingDefaultsWithInsufficientScopeThenInsufficientScopeError() throws Exception { this.spring.register(RestOperationsConfig.class, DefaultConfig.class, BasicController.class).autowire(); - mockRestOperations(jwks("Default")); + mockJwksRestOperations(jwks("Default")); String token = this.token("ValidNoScopes"); // @formatter:off this.mvc.perform(get("/requires-read-scope").with(bearerToken(token))) @@ -466,7 +468,7 @@ public void getWhenUsingDefaultsWithInsufficientScopeThenInsufficientScopeError( @Test public void getWhenUsingDefaultsWithInsufficientScpThenInsufficientScopeError() throws Exception { this.spring.register(RestOperationsConfig.class, DefaultConfig.class, BasicController.class).autowire(); - mockRestOperations(jwks("Default")); + mockJwksRestOperations(jwks("Default")); String token = this.token("ValidMessageWriteScp"); // @formatter:off this.mvc.perform(get("/requires-read-scope").with(bearerToken(token))) @@ -478,7 +480,7 @@ public void getWhenUsingDefaultsWithInsufficientScpThenInsufficientScopeError() @Test public void getWhenUsingDefaultsAndAuthorizationServerHasNoMatchingKeyThenInvalidToken() throws Exception { this.spring.register(RestOperationsConfig.class, DefaultConfig.class).autowire(); - mockRestOperations(jwks("Empty")); + mockJwksRestOperations(jwks("Empty")); String token = this.token("ValidNoScopes"); // @formatter:off this.mvc.perform(get("/").with(bearerToken(token))) @@ -490,7 +492,7 @@ public void getWhenUsingDefaultsAndAuthorizationServerHasNoMatchingKeyThenInvali @Test public void getWhenUsingDefaultsAndAuthorizationServerHasMultipleMatchingKeysThenOk() throws Exception { this.spring.register(RestOperationsConfig.class, DefaultConfig.class, BasicController.class).autowire(); - mockRestOperations(jwks("TwoKeys")); + mockJwksRestOperations(jwks("TwoKeys")); String token = this.token("ValidNoScopes"); // @formatter:off this.mvc.perform(get("/authenticated").with(bearerToken(token))) @@ -502,7 +504,7 @@ public void getWhenUsingDefaultsAndAuthorizationServerHasMultipleMatchingKeysThe @Test public void getWhenUsingDefaultsAndKeyMatchesByKidThenOk() throws Exception { this.spring.register(RestOperationsConfig.class, DefaultConfig.class, BasicController.class).autowire(); - mockRestOperations(jwks("TwoKeys")); + mockJwksRestOperations(jwks("TwoKeys")); String token = this.token("Kid"); // @formatter:off this.mvc.perform(get("/authenticated").with(bearerToken(token))) @@ -514,7 +516,7 @@ public void getWhenUsingDefaultsAndKeyMatchesByKidThenOk() throws Exception { @Test public void getWhenUsingMethodSecurityWithValidBearerTokenThenAcceptsRequest() throws Exception { this.spring.register(RestOperationsConfig.class, MethodSecurityConfig.class, BasicController.class).autowire(); - mockRestOperations(jwks("Default")); + mockJwksRestOperations(jwks("Default")); String token = this.token("ValidMessageReadScope"); // @formatter:off this.mvc.perform(get("/ms-requires-read-scope").with(bearerToken(token))) @@ -526,7 +528,7 @@ public void getWhenUsingMethodSecurityWithValidBearerTokenThenAcceptsRequest() t @Test public void getWhenUsingMethodSecurityWithValidBearerTokenHavingScpAttributeThenAcceptsRequest() throws Exception { this.spring.register(RestOperationsConfig.class, MethodSecurityConfig.class, BasicController.class).autowire(); - mockRestOperations(jwks("Default")); + mockJwksRestOperations(jwks("Default")); String token = this.token("ValidMessageReadScp"); // @formatter:off this.mvc.perform(get("/ms-requires-read-scope").with(bearerToken(token))) @@ -538,7 +540,7 @@ public void getWhenUsingMethodSecurityWithValidBearerTokenHavingScpAttributeThen @Test public void getWhenUsingMethodSecurityWithInsufficientScopeThenInsufficientScopeError() throws Exception { this.spring.register(RestOperationsConfig.class, MethodSecurityConfig.class, BasicController.class).autowire(); - mockRestOperations(jwks("Default")); + mockJwksRestOperations(jwks("Default")); String token = this.token("ValidNoScopes"); // @formatter:off this.mvc.perform(get("/ms-requires-read-scope").with(bearerToken(token))) @@ -550,7 +552,7 @@ public void getWhenUsingMethodSecurityWithInsufficientScopeThenInsufficientScope @Test public void getWhenUsingMethodSecurityWithInsufficientScpThenInsufficientScopeError() throws Exception { this.spring.register(RestOperationsConfig.class, MethodSecurityConfig.class, BasicController.class).autowire(); - mockRestOperations(jwks("Default")); + mockJwksRestOperations(jwks("Default")); String token = this.token("ValidMessageWriteScp"); // @formatter:off this.mvc.perform(get("/ms-requires-read-scope").with(bearerToken(token))) @@ -562,7 +564,7 @@ public void getWhenUsingMethodSecurityWithInsufficientScpThenInsufficientScopeEr @Test public void getWhenUsingMethodSecurityWithDenyAllThenInsufficientScopeError() throws Exception { this.spring.register(RestOperationsConfig.class, MethodSecurityConfig.class, BasicController.class).autowire(); - mockRestOperations(jwks("Default")); + mockJwksRestOperations(jwks("Default")); String token = this.token("ValidMessageReadScope"); // @formatter:off this.mvc.perform(get("/ms-deny").with(bearerToken(token))) @@ -574,7 +576,7 @@ public void getWhenUsingMethodSecurityWithDenyAllThenInsufficientScopeError() th @Test public void postWhenUsingDefaultsWithValidBearerTokenAndNoCsrfTokenThenOk() throws Exception { this.spring.register(RestOperationsConfig.class, DefaultConfig.class, BasicController.class).autowire(); - mockRestOperations(jwks("Default")); + mockJwksRestOperations(jwks("Default")); String token = this.token("ValidNoScopes"); // @formatter:off this.mvc.perform(post("/authenticated").header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE).with(bearerToken(token))) @@ -596,7 +598,7 @@ public void postWhenUsingDefaultsWithNoBearerTokenThenCsrfDenies() throws Except @Test public void postWhenUsingDefaultsWithExpiredBearerTokenAndNoCsrfThenInvalidToken() throws Exception { this.spring.register(RestOperationsConfig.class, DefaultConfig.class).autowire(); - mockRestOperations(jwks("Default")); + mockJwksRestOperations(jwks("Default")); String token = this.token("Expired"); // @formatter:off this.mvc.perform(post("/authenticated").header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE).with(bearerToken(token))) @@ -608,7 +610,7 @@ public void postWhenUsingDefaultsWithExpiredBearerTokenAndNoCsrfThenInvalidToken @Test public void requestWhenDefaultConfiguredThenSessionIsNotCreated() throws Exception { this.spring.register(RestOperationsConfig.class, DefaultConfig.class, BasicController.class).autowire(); - mockRestOperations(jwks("Default")); + mockJwksRestOperations(jwks("Default")); String token = this.token("ValidNoScopes"); // @formatter:off MvcResult result = this.mvc.perform(get("/").with(bearerToken(token))) @@ -621,7 +623,7 @@ public void requestWhenDefaultConfiguredThenSessionIsNotCreated() throws Excepti @Test public void requestWhenIntrospectionConfiguredThenSessionIsNotCreated() throws Exception { this.spring.register(RestOperationsConfig.class, OpaqueTokenConfig.class, BasicController.class).autowire(); - mockRestOperations(json("Active")); + mockJsonRestOperations(json("Active")); // @formatter:off MvcResult result = this.mvc.perform(get("/authenticated").with(bearerToken("token"))) .andExpect(status().isOk()) @@ -646,7 +648,7 @@ public void requestWhenUsingDefaultsAndNoBearerTokenThenSessionIsCreated() throw public void requestWhenSessionManagementConfiguredThenUserConfigurationOverrides() throws Exception { this.spring.register(RestOperationsConfig.class, AlwaysSessionCreationConfig.class, BasicController.class) .autowire(); - mockRestOperations(jwks("Default")); + mockJwksRestOperations(jwks("Default")); String token = this.token("ValidNoScopes"); // @formatter:off MvcResult result = this.mvc.perform(get("/").with(bearerToken(token))) @@ -917,7 +919,7 @@ public void accessDeniedHandlerWhenGivenNullThenThrowsException() { @Test public void requestWhenCustomJwtValidatorFailsThenCorrespondingErrorMessage() throws Exception { this.spring.register(RestOperationsConfig.class, CustomJwtValidatorConfig.class).autowire(); - mockRestOperations(jwks("Default")); + mockJwksRestOperations(jwks("Default")); String token = this.token("ValidNoScopes"); OAuth2TokenValidator jwtValidator = this.spring.getContext() .getBean(CustomJwtValidatorConfig.class) @@ -935,7 +937,7 @@ public void requestWhenCustomJwtValidatorFailsThenCorrespondingErrorMessage() th public void requestWhenClockSkewSetThenTimestampWindowRelaxedAccordingly() throws Exception { this.spring.register(RestOperationsConfig.class, UnexpiredJwtClockSkewConfig.class, BasicController.class) .autowire(); - mockRestOperations(jwks("Default")); + mockJwksRestOperations(jwks("Default")); String token = this.token("ExpiresAt4687177990"); // @formatter:off this.mvc.perform(get("/").with(bearerToken(token))) @@ -947,7 +949,7 @@ public void requestWhenClockSkewSetThenTimestampWindowRelaxedAccordingly() throw public void requestWhenClockSkewSetButJwtStillTooLateThenReportsExpired() throws Exception { this.spring.register(RestOperationsConfig.class, ExpiredJwtClockSkewConfig.class, BasicController.class) .autowire(); - mockRestOperations(jwks("Default")); + mockJwksRestOperations(jwks("Default")); String token = this.token("ExpiresAt4687177990"); // @formatter:off this.mvc.perform(get("/").with(bearerToken(token))) @@ -1061,7 +1063,7 @@ public void getWhenDefaultAndCustomJwtAuthenticationManagerThenCustomUsed() thro @Test public void getWhenIntrospectingThenOk() throws Exception { this.spring.register(RestOperationsConfig.class, OpaqueTokenConfig.class, BasicController.class).autowire(); - mockRestOperations(json("Active")); + mockJsonRestOperations(json("Active")); // @formatter:off this.mvc.perform(get("/authenticated").with(bearerToken("token"))) .andExpect(status().isOk()) @@ -1073,7 +1075,7 @@ public void getWhenIntrospectingThenOk() throws Exception { public void getWhenOpaqueTokenInLambdaAndIntrospectingThenOk() throws Exception { this.spring.register(RestOperationsConfig.class, OpaqueTokenInLambdaConfig.class, BasicController.class) .autowire(); - mockRestOperations(json("Active")); + mockJsonRestOperations(json("Active")); // @formatter:off this.mvc.perform(get("/authenticated").with(bearerToken("token"))) .andExpect(status().isOk()) @@ -1084,7 +1086,7 @@ public void getWhenOpaqueTokenInLambdaAndIntrospectingThenOk() throws Exception @Test public void getWhenIntrospectionFailsThenUnauthorized() throws Exception { this.spring.register(RestOperationsConfig.class, OpaqueTokenConfig.class).autowire(); - mockRestOperations(json("Inactive")); + mockJsonRestOperations(json("Inactive")); // @formatter:off this.mvc.perform(get("/").with(bearerToken("token"))) .andExpect(status().isUnauthorized()) @@ -1095,7 +1097,7 @@ public void getWhenIntrospectionFailsThenUnauthorized() throws Exception { @Test public void getWhenIntrospectionLacksScopeThenForbidden() throws Exception { this.spring.register(RestOperationsConfig.class, OpaqueTokenConfig.class).autowire(); - mockRestOperations(json("ActiveNoScopes")); + mockJsonRestOperations(json("ActiveNoScopes")); // @formatter:off this.mvc.perform(get("/requires-read-scope").with(bearerToken("token"))) .andExpect(status().isForbidden()) @@ -1252,7 +1254,7 @@ public void requestWhenDefaultAndResourceServerAccessDeniedHandlersThenMatchedBy public void getWhenAlsoUsingHttpBasicThenCorrectProviderEngages() throws Exception { this.spring.register(RestOperationsConfig.class, BasicAndResourceServerConfig.class, BasicController.class) .autowire(); - mockRestOperations(jwks("Default")); + mockJwksRestOperations(jwks("Default")); String token = this.token("ValidNoScopes"); // @formatter:off this.mvc.perform(get("/authenticated").with(bearerToken(token))) @@ -1408,7 +1410,7 @@ public void getWhenCustomAuthenticationConverterThenUsed() throws Exception { OpaqueTokenAuthenticationConverter authenticationConverter = bean(OpaqueTokenAuthenticationConverter.class); given(authenticationConverter.convert(anyString(), any(OAuth2AuthenticatedPrincipal.class))) .willReturn(new TestingAuthenticationToken("jdoe", null, Collections.emptyList())); - mockRestOperations(json("Active")); + mockJsonRestOperations(json("Active")); // @formatter:off this.mvc.perform(get("/authenticated").with(bearerToken("token"))) .andExpect(status().isOk()) @@ -1515,6 +1517,29 @@ private void mockRestOperations(String response) { given(rest.exchange(any(RequestEntity.class), eq(String.class))).willReturn(entity); } + private void mockJwksRestOperations(String response) { + RestOperations rest = this.spring.getContext().getBean(RestOperations.class); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + ResponseEntity entity = new ResponseEntity<>(response, headers, HttpStatus.OK); + given(rest.exchange(any(RequestEntity.class), eq(String.class))).willReturn(entity); + } + + private void mockJsonRestOperations(String response) { + try { + RestOperations rest = this.spring.getContext().getBean(RestOperations.class); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + ResponseEntity> entity = new ResponseEntity<>(JSONObjectUtils.parse(response), headers, + HttpStatus.OK); + given(rest.exchange(any(RequestEntity.class), eq(new ParameterizedTypeReference>() { + }))).willReturn(entity); + } + catch (Exception ex) { + throw new IllegalArgumentException(ex); + } + } + private T bean(Class beanClass) { return this.spring.getContext().getBean(beanClass); } @@ -2729,8 +2754,8 @@ NimbusJwtDecoder jwtDecoder() { } @Bean - NimbusOpaqueTokenIntrospector tokenIntrospectionClient() { - return new NimbusOpaqueTokenIntrospector("https://example.org/introspect", this.rest); + OpaqueTokenIntrospector tokenIntrospectionClient() { + return new SpringOpaqueTokenIntrospector("https://example.org/introspect", this.rest); } } diff --git a/config/src/test/java/org/springframework/security/config/http/OAuth2ResourceServerBeanDefinitionParserTests.java b/config/src/test/java/org/springframework/security/config/http/OAuth2ResourceServerBeanDefinitionParserTests.java index 5ad167eef84..4a6af4fc81c 100644 --- a/config/src/test/java/org/springframework/security/config/http/OAuth2ResourceServerBeanDefinitionParserTests.java +++ b/config/src/test/java/org/springframework/security/config/http/OAuth2ResourceServerBeanDefinitionParserTests.java @@ -36,6 +36,7 @@ import com.nimbusds.jose.crypto.RSASSASigner; import com.nimbusds.jose.jwk.JWKSet; import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.util.JSONObjectUtils; import jakarta.servlet.http.HttpServletRequest; import net.minidev.json.JSONObject; import okhttp3.mockwebserver.MockResponse; @@ -57,6 +58,7 @@ import org.springframework.beans.factory.xml.BeanDefinitionParserDelegate; import org.springframework.beans.factory.xml.ParserContext; import org.springframework.beans.factory.xml.XmlReaderContext; +import org.springframework.core.ParameterizedTypeReference; import org.springframework.core.convert.converter.Converter; import org.springframework.core.io.ClassPathResource; import org.springframework.http.HttpHeaders; @@ -84,9 +86,9 @@ import org.springframework.security.oauth2.jwt.TestJwts; import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthenticationToken; 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.OpaqueTokenAuthenticationConverter; import org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector; +import org.springframework.security.oauth2.server.resource.introspection.SpringOpaqueTokenIntrospector; import org.springframework.security.oauth2.server.resource.web.BearerTokenResolver; import org.springframework.security.test.context.annotation.SecurityTestExecutionListeners; import org.springframework.security.web.authentication.AuthenticationConverter; @@ -139,7 +141,7 @@ public class OAuth2ResourceServerBeanDefinitionParserTests { @Test public void getWhenValidBearerTokenThenAcceptsRequest() throws Exception { this.spring.configLocations(xml("JwtRestOperations"), xml("Jwt")).autowire(); - mockRestOperations(jwks("Default")); + mockJwksRestOperations(jwks("Default")); String token = this.token("ValidNoScopes"); // @formatter:off this.mvc.perform(get("/").header("Authorization", "Bearer " + token)) @@ -150,7 +152,7 @@ public void getWhenValidBearerTokenThenAcceptsRequest() throws Exception { @Test public void getWhenCustomSecurityContextHolderStrategyThenUses() throws Exception { this.spring.configLocations(xml("JwtRestOperations"), xml("JwtCustomSecurityContextHolderStrategy")).autowire(); - mockRestOperations(jwks("Default")); + mockJwksRestOperations(jwks("Default")); String token = this.token("ValidNoScopes"); // @formatter:off this.mvc.perform(get("/").header("Authorization", "Bearer " + token)) @@ -175,7 +177,7 @@ public void getWhenUsingJwkSetUriThenAcceptsRequest() throws Exception { @Test public void getWhenExpiredBearerTokenThenInvalidToken() throws Exception { this.spring.configLocations(xml("JwtRestOperations"), xml("Jwt")).autowire(); - mockRestOperations(jwks("Default")); + mockJwksRestOperations(jwks("Default")); String token = this.token("Expired"); // @formatter:off this.mvc.perform(get("/").header("Authorization", "Bearer " + token)) @@ -187,7 +189,7 @@ public void getWhenExpiredBearerTokenThenInvalidToken() throws Exception { @Test public void getWhenBadJwkEndpointThen500() throws Exception { this.spring.configLocations(xml("JwtRestOperations"), xml("Jwt")).autowire(); - mockRestOperations("malformed"); + mockJwksRestOperations("malformed"); String token = this.token("ValidNoScopes"); // @formatter:off assertThatExceptionOfType(AuthenticationServiceException.class) @@ -219,7 +221,7 @@ public void getWhenMalformedBearerTokenThenInvalidToken() throws Exception { @Test public void getWhenMalformedPayloadThenInvalidToken() throws Exception { this.spring.configLocations(xml("JwtRestOperations"), xml("Jwt")).autowire(); - mockRestOperations(jwks("Default")); + mockJwksRestOperations(jwks("Default")); String token = this.token("MalformedPayload"); // @formatter:off this.mvc.perform(get("/").header("Authorization", "Bearer " + token)) @@ -242,7 +244,7 @@ public void getWhenUnsignedBearerTokenThenInvalidToken() throws Exception { @Test public void getWhenBearerTokenBeforeNotBeforeThenInvalidToken() throws Exception { this.spring.configLocations(xml("JwtRestOperations"), xml("Jwt")).autowire(); - this.mockRestOperations(jwks("Default")); + this.mockJwksRestOperations(jwks("Default")); String token = this.token("TooEarly"); // @formatter:off this.mvc.perform(get("/").header("Authorization", "Bearer " + token)) @@ -299,7 +301,7 @@ public void getWhenNoBearerTokenThenUnauthorized() throws Exception { @Test public void getWhenSufficientlyScopedBearerTokenThenAcceptsRequest() throws Exception { this.spring.configLocations(xml("JwtRestOperations"), xml("Jwt")).autowire(); - mockRestOperations(jwks("Default")); + mockJwksRestOperations(jwks("Default")); String token = this.token("ValidMessageReadScope"); // @formatter:off this.mvc.perform(get("/requires-read-scope").header("Authorization", "Bearer " + token)) @@ -310,7 +312,7 @@ public void getWhenSufficientlyScopedBearerTokenThenAcceptsRequest() throws Exce @Test public void getWhenInsufficientScopeThenInsufficientScopeError() throws Exception { this.spring.configLocations(xml("JwtRestOperations"), xml("Jwt")).autowire(); - mockRestOperations(jwks("Default")); + mockJwksRestOperations(jwks("Default")); String token = this.token("ValidNoScopes"); // @formatter:off this.mvc.perform(get("/requires-read-scope").header("Authorization", "Bearer " + token)) @@ -322,7 +324,7 @@ public void getWhenInsufficientScopeThenInsufficientScopeError() throws Exceptio @Test public void getWhenInsufficientScpThenInsufficientScopeError() throws Exception { this.spring.configLocations(xml("JwtRestOperations"), xml("Jwt")).autowire(); - mockRestOperations(jwks("Default")); + mockJwksRestOperations(jwks("Default")); String token = this.token("ValidMessageWriteScp"); // @formatter:off this.mvc.perform(get("/requires-read-scope").header("Authorization", "Bearer " + token)) @@ -334,7 +336,7 @@ public void getWhenInsufficientScpThenInsufficientScopeError() throws Exception @Test public void getWhenAuthorizationServerHasNoMatchingKeyThenInvalidToken() throws Exception { this.spring.configLocations(xml("JwtRestOperations"), xml("Jwt")).autowire(); - mockRestOperations(jwks("Empty")); + mockJwksRestOperations(jwks("Empty")); String token = this.token("ValidNoScopes"); // @formatter:off this.mvc.perform(get("/").header("Authorization", "Bearer " + token)) @@ -346,7 +348,7 @@ public void getWhenAuthorizationServerHasNoMatchingKeyThenInvalidToken() throws @Test public void getWhenAuthorizationServerHasMultipleMatchingKeysThenOk() throws Exception { this.spring.configLocations(xml("JwtRestOperations"), xml("Jwt")).autowire(); - mockRestOperations(jwks("TwoKeys")); + mockJwksRestOperations(jwks("TwoKeys")); String token = this.token("ValidNoScopes"); // @formatter:off this.mvc.perform(get("/authenticated").header("Authorization", "Bearer " + token)) @@ -357,7 +359,7 @@ public void getWhenAuthorizationServerHasMultipleMatchingKeysThenOk() throws Exc @Test public void getWhenKeyMatchesByKidThenOk() throws Exception { this.spring.configLocations(xml("JwtRestOperations"), xml("Jwt")).autowire(); - mockRestOperations(jwks("TwoKeys")); + mockJwksRestOperations(jwks("TwoKeys")); String token = this.token("Kid"); // @formatter:off this.mvc.perform(get("/authenticated").header("Authorization", "Bearer " + token)) @@ -368,7 +370,7 @@ public void getWhenKeyMatchesByKidThenOk() throws Exception { @Test public void postWhenValidBearerTokenAndNoCsrfTokenThenOk() throws Exception { this.spring.configLocations(xml("JwtRestOperations"), xml("Jwt")).autowire(); - mockRestOperations(jwks("Default")); + mockJwksRestOperations(jwks("Default")); String token = this.token("ValidNoScopes"); // @formatter:off this.mvc.perform(post("/authenticated").header("Authorization", "Bearer " + token)) @@ -390,7 +392,7 @@ public void postWhenNoBearerTokenThenCsrfDenies() throws Exception { @Test public void postWhenExpiredBearerTokenAndNoCsrfThenInvalidToken() throws Exception { this.spring.configLocations(xml("JwtRestOperations"), xml("Jwt")).autowire(); - mockRestOperations(jwks("Default")); + mockJwksRestOperations(jwks("Default")); String token = this.token("Expired"); // @formatter:off this.mvc.perform(post("/authenticated").header("Authorization", "Bearer " + token)) @@ -402,7 +404,7 @@ public void postWhenExpiredBearerTokenAndNoCsrfThenInvalidToken() throws Excepti @Test public void requestWhenJwtThenSessionIsNotCreated() throws Exception { this.spring.configLocations(xml("JwtRestOperations"), xml("Jwt")).autowire(); - mockRestOperations(jwks("Default")); + mockJwksRestOperations(jwks("Default")); String token = this.token("ValidNoScopes"); // @formatter:off MvcResult result = this.mvc.perform(get("/").header("Authorization", "Bearer " + token)) @@ -438,7 +440,7 @@ public void requestWhenNoBearerTokenThenSessionIsCreated() throws Exception { @Test public void requestWhenSessionManagementConfiguredThenUses() throws Exception { this.spring.configLocations(xml("JwtRestOperations"), xml("AlwaysSessionCreation")).autowire(); - mockRestOperations(jwks("Default")); + mockJwksRestOperations(jwks("Default")); String token = this.token("ValidNoScopes"); // @formatter:off MvcResult result = this.mvc.perform(get("/").header("Authorization", "Bearer " + token)) @@ -587,7 +589,7 @@ public void requestWhenRealmNameConfiguredThenUsesOnAccessDenied() throws Except @Test public void requestWhenCustomJwtValidatorFailsThenCorrespondingErrorMessage() throws Exception { this.spring.configLocations(xml("MockJwtValidator"), xml("Jwt")).autowire(); - mockRestOperations(jwks("Default")); + mockJwksRestOperations(jwks("Default")); String token = this.token("ValidNoScopes"); OAuth2TokenValidator jwtValidator = this.spring.getContext().getBean(OAuth2TokenValidator.class); OAuth2Error error = new OAuth2Error("custom-error", "custom-description", "custom-uri"); @@ -602,7 +604,7 @@ public void requestWhenCustomJwtValidatorFailsThenCorrespondingErrorMessage() th @Test public void requestWhenClockSkewSetThenTimestampWindowRelaxedAccordingly() throws Exception { this.spring.configLocations(xml("UnexpiredJwtClockSkew"), xml("Jwt")).autowire(); - mockRestOperations(jwks("Default")); + mockJwksRestOperations(jwks("Default")); String token = this.token("ExpiresAt4687177990"); // @formatter:off this.mvc.perform(get("/").header("Authorization", "Bearer " + token)) @@ -613,7 +615,7 @@ public void requestWhenClockSkewSetThenTimestampWindowRelaxedAccordingly() throw @Test public void requestWhenClockSkewSetButJwtStillTooLateThenReportsExpired() throws Exception { this.spring.configLocations(xml("ExpiredJwtClockSkew"), xml("Jwt")).autowire(); - mockRestOperations(jwks("Default")); + mockJwksRestOperations(jwks("Default")); String token = this.token("ExpiresAt4687177990"); // @formatter:off this.mvc.perform(get("/").header("Authorization", "Bearer " + token)) @@ -675,7 +677,7 @@ public void requestWhenUsingPublicKeyAlgorithmDoesNotMatchThenReturnsInvalidToke @Test public void getWhenIntrospectingThenOk() throws Exception { this.spring.configLocations(xml("OpaqueTokenRestOperations"), xml("OpaqueToken")).autowire(); - mockRestOperations(json("Active")); + mockJsonRestOperations(json("Active")); // @formatter:off this.mvc.perform(get("/authenticated").header("Authorization", "Bearer token")) .andExpect(status().isNotFound()); @@ -686,7 +688,7 @@ public void getWhenIntrospectingThenOk() throws Exception { public void configureWhenIntrospectingWithAuthenticationConverterThenUses() throws Exception { this.spring.configLocations(xml("OpaqueTokenRestOperations"), xml("OpaqueTokenAndAuthenticationConverter")) .autowire(); - mockRestOperations(json("Active")); + mockJsonRestOperations(json("Active")); OpaqueTokenAuthenticationConverter converter = bean(OpaqueTokenAuthenticationConverter.class); given(converter.convert(any(), any())).willReturn(new TestingAuthenticationToken("user", "pass", "app")); // @formatter:off @@ -699,7 +701,7 @@ public void configureWhenIntrospectingWithAuthenticationConverterThenUses() thro @Test public void getWhenIntrospectionFailsThenUnauthorized() throws Exception { this.spring.configLocations(xml("OpaqueTokenRestOperations"), xml("OpaqueToken")).autowire(); - mockRestOperations(json("Inactive")); + mockJsonRestOperations(json("Inactive")); // @formatter:off MockHttpServletRequestBuilder request = get("/") .header("Authorization", "Bearer token"); @@ -712,7 +714,7 @@ public void getWhenIntrospectionFailsThenUnauthorized() throws Exception { @Test public void getWhenIntrospectionLacksScopeThenForbidden() throws Exception { this.spring.configLocations(xml("OpaqueTokenRestOperations"), xml("OpaqueToken")).autowire(); - mockRestOperations(json("ActiveNoScopes")); + mockJsonRestOperations(json("ActiveNoScopes")); // @formatter:off this.mvc.perform(get("/requires-read-scope").header("Authorization", "Bearer token")) .andExpect(status().isForbidden()) @@ -818,7 +820,7 @@ public void requestWhenFormLoginAndResourceServerEntryPointsThenSessionCreatedBy @Test public void getWhenAlsoUsingHttpBasicThenCorrectProviderEngages() throws Exception { this.spring.configLocations(xml("JwtRestOperations"), xml("BasicAndResourceServer")).autowire(); - mockRestOperations(jwks("Default")); + mockJwksRestOperations(jwks("Default")); String token = this.token("ValidNoScopes"); // @formatter:off this.mvc.perform(get("/authenticated").header("Authorization", "Bearer " + token)) @@ -963,7 +965,7 @@ private void mockWebServer(String response) { .setBody(response)); } - private void mockRestOperations(String response) { + private void mockJwksRestOperations(String response) { RestOperations rest = this.spring.getContext().getBean(RestOperations.class); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); @@ -971,6 +973,21 @@ private void mockRestOperations(String response) { given(rest.exchange(any(RequestEntity.class), eq(String.class))).willReturn(entity); } + private void mockJsonRestOperations(String response) { + try { + RestOperations rest = this.spring.getContext().getBean(RestOperations.class); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + ResponseEntity> entity = new ResponseEntity<>(JSONObjectUtils.parse(response), headers, + HttpStatus.OK); + given(rest.exchange(any(RequestEntity.class), eq(new ParameterizedTypeReference>() { + }))).willReturn(entity); + } + catch (Exception ex) { + throw new IllegalArgumentException(ex); + } + } + private String json(String name) throws IOException { return resource(name + ".json"); } @@ -1047,7 +1064,7 @@ static class OpaqueTokenIntrospectorFactoryBean implements FactoryBean = mapOf( + "active" to true, + "sub" to "test-subject", + "scope" to "message:read", + "exp" to 4683883211 + ) every { - DefaultOpaqueConfig.REST.exchange(any(), eq(String::class.java)) - } returns entity + DefaultOpaqueConfig.REST.exchange(any(), any>>()) + } returns ResponseEntity(responseBody, headers, HttpStatus.OK) this.mockMvc.get("/authenticated") { header("Authorization", "Bearer token") @@ -127,8 +127,8 @@ class OpaqueTokenDslTests { open fun rest(): RestOperations = REST @Bean - open fun tokenIntrospectionClient(): NimbusOpaqueTokenIntrospector { - return NimbusOpaqueTokenIntrospector("https://example.org/introspect", REST) + open fun tokenIntrospectionClient(): OpaqueTokenIntrospector { + return SpringOpaqueTokenIntrospector("https://example.org/introspect", REST) } } diff --git a/config/src/test/kotlin/org/springframework/security/config/web/server/ServerOpaqueTokenDslTests.kt b/config/src/test/kotlin/org/springframework/security/config/web/server/ServerOpaqueTokenDslTests.kt index 3128214c619..387b60dc5cd 100644 --- a/config/src/test/kotlin/org/springframework/security/config/web/server/ServerOpaqueTokenDslTests.kt +++ b/config/src/test/kotlin/org/springframework/security/config/web/server/ServerOpaqueTokenDslTests.kt @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2025 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. @@ -28,8 +28,8 @@ import org.springframework.http.HttpHeaders import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity import org.springframework.security.config.test.SpringTestContext import org.springframework.security.config.test.SpringTestContextExtension -import org.springframework.security.oauth2.server.resource.introspection.NimbusReactiveOpaqueTokenIntrospector import org.springframework.security.oauth2.server.resource.introspection.ReactiveOpaqueTokenIntrospector +import org.springframework.security.oauth2.server.resource.introspection.SpringReactiveOpaqueTokenIntrospector import org.springframework.security.web.server.SecurityWebFilterChain import org.springframework.test.web.reactive.server.WebTestClient import org.springframework.web.reactive.config.EnableWebFlux @@ -103,7 +103,7 @@ class ServerOpaqueTokenDslTests { @Bean open fun tokenIntrospectionClient(): ReactiveOpaqueTokenIntrospector { - return NimbusReactiveOpaqueTokenIntrospector(mockWebServer().url("/introspect").toString(), "client", "secret") + return SpringReactiveOpaqueTokenIntrospector(mockWebServer().url("/introspect").toString(), "client", "secret") } } @@ -138,7 +138,7 @@ class ServerOpaqueTokenDslTests { } oauth2ResourceServer { opaqueToken { - introspector = NimbusReactiveOpaqueTokenIntrospector(mockWebServer().url("/introspector").toString(), "client", "secret") + introspector = SpringReactiveOpaqueTokenIntrospector(mockWebServer().url("/introspector").toString(), "client", "secret") } } } diff --git a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/NimbusOpaqueTokenIntrospector.java b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/NimbusOpaqueTokenIntrospector.java deleted file mode 100644 index d12fee4eeda..00000000000 --- a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/NimbusOpaqueTokenIntrospector.java +++ /dev/null @@ -1,269 +0,0 @@ -/* - * Copyright 2002-2021 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.introspection; - -import java.net.URI; -import java.time.Instant; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.List; -import java.util.Map; - -import com.nimbusds.oauth2.sdk.ErrorObject; -import com.nimbusds.oauth2.sdk.TokenIntrospectionResponse; -import com.nimbusds.oauth2.sdk.TokenIntrospectionSuccessResponse; -import com.nimbusds.oauth2.sdk.http.HTTPResponse; -import com.nimbusds.oauth2.sdk.id.Audience; -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; - -import org.springframework.core.convert.converter.Converter; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; -import org.springframework.http.MediaType; -import org.springframework.http.RequestEntity; -import org.springframework.http.ResponseEntity; -import org.springframework.http.client.support.BasicAuthenticationInterceptor; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.oauth2.core.OAuth2AuthenticatedPrincipal; -import org.springframework.security.oauth2.core.OAuth2TokenIntrospectionClaimNames; -import org.springframework.util.Assert; -import org.springframework.util.LinkedMultiValueMap; -import org.springframework.util.MultiValueMap; -import org.springframework.web.client.RestOperations; -import org.springframework.web.client.RestTemplate; - -/** - * A Nimbus implementation of {@link OpaqueTokenIntrospector} that verifies and - * introspects a token using the configured - * OAuth 2.0 Introspection - * Endpoint. - * - * @author Josh Cummings - * @author MD Sayem Ahmed - * @since 5.2 - * @deprecated Please use {@link SpringOpaqueTokenIntrospector} instead - */ -@Deprecated -public class NimbusOpaqueTokenIntrospector implements OpaqueTokenIntrospector { - - private static final String AUTHORITY_PREFIX = "SCOPE_"; - - private final Log logger = LogFactory.getLog(getClass()); - - private final RestOperations restOperations; - - private Converter> requestEntityConverter; - - /** - * Creates a {@code OpaqueTokenAuthenticationProvider} with the provided parameters - * @param introspectionUri The introspection endpoint uri - * @param clientId The client id authorized to introspect - * @param clientSecret The client's secret - */ - public NimbusOpaqueTokenIntrospector(String introspectionUri, String clientId, String clientSecret) { - Assert.notNull(introspectionUri, "introspectionUri cannot be null"); - Assert.notNull(clientId, "clientId cannot be null"); - Assert.notNull(clientSecret, "clientSecret cannot be null"); - this.requestEntityConverter = this.defaultRequestEntityConverter(URI.create(introspectionUri)); - RestTemplate restTemplate = new RestTemplate(); - restTemplate.getInterceptors().add(new BasicAuthenticationInterceptor(clientId, clientSecret)); - this.restOperations = restTemplate; - } - - /** - * Creates a {@code OpaqueTokenAuthenticationProvider} with the provided parameters - * - * The given {@link RestOperations} should perform its own client authentication - * against the introspection endpoint. - * @param introspectionUri The introspection endpoint uri - * @param restOperations The client for performing the introspection request - */ - public NimbusOpaqueTokenIntrospector(String introspectionUri, RestOperations restOperations) { - Assert.notNull(introspectionUri, "introspectionUri cannot be null"); - Assert.notNull(restOperations, "restOperations cannot be null"); - this.requestEntityConverter = this.defaultRequestEntityConverter(URI.create(introspectionUri)); - this.restOperations = restOperations; - } - - private Converter> defaultRequestEntityConverter(URI introspectionUri) { - return (token) -> { - HttpHeaders headers = requestHeaders(); - MultiValueMap body = requestBody(token); - return new RequestEntity<>(body, headers, HttpMethod.POST, introspectionUri); - }; - } - - private HttpHeaders requestHeaders() { - HttpHeaders headers = new HttpHeaders(); - headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); - return headers; - } - - private MultiValueMap requestBody(String token) { - MultiValueMap body = new LinkedMultiValueMap<>(); - body.add("token", token); - return body; - } - - @Override - public OAuth2AuthenticatedPrincipal introspect(String token) { - RequestEntity requestEntity = this.requestEntityConverter.convert(token); - if (requestEntity == null) { - throw new OAuth2IntrospectionException("requestEntityConverter returned a null entity"); - } - ResponseEntity responseEntity = makeRequest(requestEntity); - HTTPResponse httpResponse = adaptToNimbusResponse(responseEntity); - TokenIntrospectionResponse introspectionResponse = parseNimbusResponse(httpResponse); - TokenIntrospectionSuccessResponse introspectionSuccessResponse = castToNimbusSuccess(introspectionResponse); - // relying solely on the authorization server to validate this token (not checking - // 'exp', for example) - if (!introspectionSuccessResponse.isActive()) { - this.logger.trace("Did not validate token since it is inactive"); - throw new BadOpaqueTokenException("Provided token isn't active"); - } - return convertClaimsSet(introspectionSuccessResponse); - } - - /** - * Sets the {@link Converter} used for converting the OAuth 2.0 access token to a - * {@link RequestEntity} representation of the OAuth 2.0 token introspection request. - * @param requestEntityConverter the {@link Converter} used for converting to a - * {@link RequestEntity} representation of the token introspection request - */ - public void setRequestEntityConverter(Converter> requestEntityConverter) { - Assert.notNull(requestEntityConverter, "requestEntityConverter cannot be null"); - this.requestEntityConverter = requestEntityConverter; - } - - private ResponseEntity makeRequest(RequestEntity requestEntity) { - try { - return this.restOperations.exchange(requestEntity, String.class); - } - catch (Exception ex) { - throw new OAuth2IntrospectionException(ex.getMessage(), ex); - } - } - - private HTTPResponse adaptToNimbusResponse(ResponseEntity responseEntity) { - MediaType contentType = responseEntity.getHeaders().getContentType(); - - if (contentType == null) { - this.logger.trace("Did not receive Content-Type from introspection endpoint in response"); - - throw new OAuth2IntrospectionException( - "Introspection endpoint response was invalid, as no Content-Type header was provided"); - } - - // Nimbus expects JSON, but does not appear to validate this header first. - if (!contentType.isCompatibleWith(MediaType.APPLICATION_JSON)) { - this.logger.trace("Did not receive JSON-compatible Content-Type from introspection endpoint in response"); - - throw new OAuth2IntrospectionException("Introspection endpoint response was invalid, as content type '" - + contentType + "' is not compatible with JSON"); - } - - HTTPResponse response = new HTTPResponse(responseEntity.getStatusCode().value()); - response.setHeader(HttpHeaders.CONTENT_TYPE, contentType.toString()); - response.setContent(responseEntity.getBody()); - - if (response.getStatusCode() != HTTPResponse.SC_OK) { - this.logger.trace("Introspection endpoint returned non-OK status code"); - - throw new OAuth2IntrospectionException( - "Introspection endpoint responded with HTTP status code " + response.getStatusCode()); - } - return response; - } - - private TokenIntrospectionResponse parseNimbusResponse(HTTPResponse response) { - try { - return TokenIntrospectionResponse.parse(response); - } - catch (Exception ex) { - throw new OAuth2IntrospectionException(ex.getMessage(), ex); - } - } - - private TokenIntrospectionSuccessResponse castToNimbusSuccess(TokenIntrospectionResponse introspectionResponse) { - if (!introspectionResponse.indicatesSuccess()) { - ErrorObject errorObject = introspectionResponse.toErrorResponse().getErrorObject(); - String message = "Token introspection failed with response " + errorObject.toJSONObject().toJSONString(); - this.logger.trace(message); - throw new OAuth2IntrospectionException(message); - } - return (TokenIntrospectionSuccessResponse) introspectionResponse; - } - - private OAuth2AuthenticatedPrincipal convertClaimsSet(TokenIntrospectionSuccessResponse response) { - Collection authorities = new ArrayList<>(); - Map claims = response.toJSONObject(); - if (response.getAudience() != null) { - List audiences = new ArrayList<>(); - for (Audience audience : response.getAudience()) { - audiences.add(audience.getValue()); - } - claims.put(OAuth2TokenIntrospectionClaimNames.AUD, Collections.unmodifiableList(audiences)); - } - if (response.getClientID() != null) { - claims.put(OAuth2TokenIntrospectionClaimNames.CLIENT_ID, response.getClientID().getValue()); - } - if (response.getExpirationTime() != null) { - Instant exp = response.getExpirationTime().toInstant(); - claims.put(OAuth2TokenIntrospectionClaimNames.EXP, exp); - } - if (response.getIssueTime() != null) { - Instant iat = response.getIssueTime().toInstant(); - claims.put(OAuth2TokenIntrospectionClaimNames.IAT, iat); - } - if (response.getIssuer() != null) { - // RFC-7662 page 7 directs users to RFC-7519 for defining the values of these - // issuer fields. - // https://datatracker.ietf.org/doc/html/rfc7662#page-7 - // - // RFC-7519 page 9 defines issuer fields as being 'case-sensitive' strings - // containing - // a 'StringOrURI', which is defined on page 5 as being any string, but - // strings containing ':' - // should be treated as valid URIs. - // https://datatracker.ietf.org/doc/html/rfc7519#section-2 - // - // It is not defined however as to whether-or-not normalized URIs should be - // treated as the same literal - // value. It only defines validation itself, so to avoid potential ambiguity - // or unwanted side effects that - // may be awkward to debug, we do not want to manipulate this value. Previous - // versions of Spring Security - // would *only* allow valid URLs, which is not what we wish to achieve here. - claims.put(OAuth2TokenIntrospectionClaimNames.ISS, response.getIssuer().getValue()); - } - if (response.getNotBeforeTime() != null) { - claims.put(OAuth2TokenIntrospectionClaimNames.NBF, response.getNotBeforeTime().toInstant()); - } - if (response.getScope() != null) { - List scopes = Collections.unmodifiableList(response.getScope().toStringList()); - claims.put(OAuth2TokenIntrospectionClaimNames.SCOPE, scopes); - for (String scope : scopes) { - authorities.add(new SimpleGrantedAuthority(AUTHORITY_PREFIX + scope)); - } - } - return new OAuth2IntrospectionAuthenticatedPrincipal(claims, authorities); - } - -} diff --git a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/NimbusReactiveOpaqueTokenIntrospector.java b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/NimbusReactiveOpaqueTokenIntrospector.java deleted file mode 100644 index 65932483609..00000000000 --- a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/NimbusReactiveOpaqueTokenIntrospector.java +++ /dev/null @@ -1,240 +0,0 @@ -/* - * Copyright 2002-2021 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.introspection; - -import java.net.URI; -import java.time.Instant; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.List; -import java.util.Map; - -import com.nimbusds.oauth2.sdk.ErrorObject; -import com.nimbusds.oauth2.sdk.TokenIntrospectionResponse; -import com.nimbusds.oauth2.sdk.TokenIntrospectionSuccessResponse; -import com.nimbusds.oauth2.sdk.http.HTTPResponse; -import com.nimbusds.oauth2.sdk.id.Audience; -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; -import reactor.core.publisher.Mono; - -import org.springframework.core.io.buffer.DataBuffer; -import org.springframework.core.io.buffer.DataBufferUtils; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.oauth2.core.OAuth2AuthenticatedPrincipal; -import org.springframework.security.oauth2.core.OAuth2TokenIntrospectionClaimNames; -import org.springframework.util.Assert; -import org.springframework.web.reactive.function.BodyInserters; -import org.springframework.web.reactive.function.client.ClientResponse; -import org.springframework.web.reactive.function.client.WebClient; - -/** - * A Nimbus implementation of {@link ReactiveOpaqueTokenIntrospector} that verifies and - * introspects a token using the configured - * OAuth 2.0 Introspection - * Endpoint. - * - * @author Josh Cummings - * @since 5.2 - * @deprecated Please use {@link SpringReactiveOpaqueTokenIntrospector} instead - */ -@Deprecated -public class NimbusReactiveOpaqueTokenIntrospector implements ReactiveOpaqueTokenIntrospector { - - private static final String AUTHORITY_PREFIX = "SCOPE_"; - - private final Log logger = LogFactory.getLog(getClass()); - - private final URI introspectionUri; - - private final WebClient webClient; - - /** - * Creates a {@code OpaqueTokenReactiveAuthenticationManager} with the provided - * parameters - * @param introspectionUri The introspection endpoint uri - * @param clientId The client id authorized to introspect - * @param clientSecret The client secret for the authorized client - */ - public NimbusReactiveOpaqueTokenIntrospector(String introspectionUri, String clientId, String clientSecret) { - Assert.hasText(introspectionUri, "introspectionUri cannot be empty"); - Assert.hasText(clientId, "clientId cannot be empty"); - Assert.notNull(clientSecret, "clientSecret cannot be null"); - this.introspectionUri = URI.create(introspectionUri); - this.webClient = WebClient.builder().defaultHeaders((h) -> h.setBasicAuth(clientId, clientSecret)).build(); - } - - /** - * Creates a {@code OpaqueTokenReactiveAuthenticationManager} with the provided - * parameters - * @param introspectionUri The introspection endpoint uri - * @param webClient The client for performing the introspection request - */ - public NimbusReactiveOpaqueTokenIntrospector(String introspectionUri, WebClient webClient) { - Assert.hasText(introspectionUri, "introspectionUri cannot be null"); - Assert.notNull(webClient, "webClient cannot be null"); - this.introspectionUri = URI.create(introspectionUri); - this.webClient = webClient; - } - - @Override - public Mono introspect(String token) { - // @formatter:off - return this.makeRequest(token) - .exchangeToMono(this::adaptToNimbusResponse) - .map(this::parseNimbusResponse) - .map(this::castToNimbusSuccess) - .doOnNext((response) -> validate(token, response)) - .map(this::convertClaimsSet) - .onErrorMap((e) -> !(e instanceof OAuth2IntrospectionException), this::onError); - // @formatter:on - } - - private WebClient.RequestHeadersSpec makeRequest(String token) { - // @formatter:off - return this.webClient.post() - .uri(this.introspectionUri) - .header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) - .body(BodyInserters.fromFormData("token", token)); - // @formatter:on - } - - private Mono adaptToNimbusResponse(ClientResponse responseEntity) { - MediaType contentType = responseEntity.headers().contentType().orElseThrow(() -> { - this.logger.trace("Did not receive Content-Type from introspection endpoint in response"); - - return new OAuth2IntrospectionException( - "Introspection endpoint response was invalid, as no Content-Type header was provided"); - }); - - // Nimbus expects JSON, but does not appear to validate this header first. - if (!contentType.isCompatibleWith(MediaType.APPLICATION_JSON)) { - this.logger.trace("Did not receive JSON-compatible Content-Type from introspection endpoint in response"); - - throw new OAuth2IntrospectionException("Introspection endpoint response was invalid, as content type '" - + contentType + "' is not compatible with JSON"); - } - - HTTPResponse response = new HTTPResponse(responseEntity.statusCode().value()); - response.setHeader(HttpHeaders.CONTENT_TYPE, contentType.toString()); - if (response.getStatusCode() != HTTPResponse.SC_OK) { - this.logger.trace("Introspection endpoint returned non-OK status code"); - - // @formatter:off - return responseEntity.bodyToFlux(DataBuffer.class) - .map(DataBufferUtils::release) - .then(Mono.error(new OAuth2IntrospectionException( - "Introspection endpoint responded with HTTP status code " + response.getStatusCode())) - ); - // @formatter:on - } - return responseEntity.bodyToMono(String.class).doOnNext(response::setContent).map((body) -> response); - } - - private TokenIntrospectionResponse parseNimbusResponse(HTTPResponse response) { - try { - return TokenIntrospectionResponse.parse(response); - } - catch (Exception ex) { - throw new OAuth2IntrospectionException(ex.getMessage(), ex); - } - } - - private TokenIntrospectionSuccessResponse castToNimbusSuccess(TokenIntrospectionResponse introspectionResponse) { - if (!introspectionResponse.indicatesSuccess()) { - ErrorObject errorObject = introspectionResponse.toErrorResponse().getErrorObject(); - String message = "Token introspection failed with response " + errorObject.toJSONObject().toJSONString(); - this.logger.trace(message); - throw new OAuth2IntrospectionException(message); - } - return (TokenIntrospectionSuccessResponse) introspectionResponse; - } - - private void validate(String token, TokenIntrospectionSuccessResponse response) { - // relying solely on the authorization server to validate this token (not checking - // 'exp', for example) - if (!response.isActive()) { - this.logger.trace("Did not validate token since it is inactive"); - throw new BadOpaqueTokenException("Provided token isn't active"); - } - } - - private OAuth2AuthenticatedPrincipal convertClaimsSet(TokenIntrospectionSuccessResponse response) { - Map claims = response.toJSONObject(); - Collection authorities = new ArrayList<>(); - if (response.getAudience() != null) { - List audiences = new ArrayList<>(); - for (Audience audience : response.getAudience()) { - audiences.add(audience.getValue()); - } - claims.put(OAuth2TokenIntrospectionClaimNames.AUD, Collections.unmodifiableList(audiences)); - } - if (response.getClientID() != null) { - claims.put(OAuth2TokenIntrospectionClaimNames.CLIENT_ID, response.getClientID().getValue()); - } - if (response.getExpirationTime() != null) { - Instant exp = response.getExpirationTime().toInstant(); - claims.put(OAuth2TokenIntrospectionClaimNames.EXP, exp); - } - if (response.getIssueTime() != null) { - Instant iat = response.getIssueTime().toInstant(); - claims.put(OAuth2TokenIntrospectionClaimNames.IAT, iat); - } - if (response.getIssuer() != null) { - // RFC-7662 page 7 directs users to RFC-7519 for defining the values of these - // issuer fields. - // https://datatracker.ietf.org/doc/html/rfc7662#page-7 - // - // RFC-7519 page 9 defines issuer fields as being 'case-sensitive' strings - // containing - // a 'StringOrURI', which is defined on page 5 as being any string, but - // strings containing ':' - // should be treated as valid URIs. - // https://datatracker.ietf.org/doc/html/rfc7519#section-2 - // - // It is not defined however as to whether-or-not normalized URIs should be - // treated as the same literal - // value. It only defines validation itself, so to avoid potential ambiguity - // or unwanted side effects that - // may be awkward to debug, we do not want to manipulate this value. Previous - // versions of Spring Security - // would *only* allow valid URLs, which is not what we wish to achieve here. - claims.put(OAuth2TokenIntrospectionClaimNames.ISS, response.getIssuer().getValue()); - } - if (response.getNotBeforeTime() != null) { - claims.put(OAuth2TokenIntrospectionClaimNames.NBF, response.getNotBeforeTime().toInstant()); - } - if (response.getScope() != null) { - List scopes = Collections.unmodifiableList(response.getScope().toStringList()); - claims.put(OAuth2TokenIntrospectionClaimNames.SCOPE, scopes); - - for (String scope : scopes) { - authorities.add(new SimpleGrantedAuthority(AUTHORITY_PREFIX + scope)); - } - } - return new OAuth2IntrospectionAuthenticatedPrincipal(claims, authorities); - } - - private OAuth2IntrospectionException onError(Throwable ex) { - return new OAuth2IntrospectionException(ex.getMessage(), ex); - } - -} diff --git a/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/introspection/NimbusOpaqueTokenIntrospectorTests.java b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/introspection/NimbusOpaqueTokenIntrospectorTests.java deleted file mode 100644 index 1177c44390e..00000000000 --- a/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/introspection/NimbusOpaqueTokenIntrospectorTests.java +++ /dev/null @@ -1,383 +0,0 @@ -/* - * Copyright 2002-2021 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.introspection; - -import java.io.IOException; -import java.time.Instant; -import java.util.Arrays; -import java.util.Base64; -import java.util.HashMap; -import java.util.Map; -import java.util.Optional; - -import net.minidev.json.JSONArray; -import net.minidev.json.JSONObject; -import okhttp3.mockwebserver.Dispatcher; -import okhttp3.mockwebserver.MockResponse; -import okhttp3.mockwebserver.MockWebServer; -import okhttp3.mockwebserver.RecordedRequest; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; - -import org.springframework.core.convert.converter.Converter; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.http.RequestEntity; -import org.springframework.http.ResponseEntity; -import org.springframework.security.oauth2.core.OAuth2AuthenticatedPrincipal; -import org.springframework.security.oauth2.core.OAuth2TokenIntrospectionClaimNames; -import org.springframework.web.client.RestOperations; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatExceptionOfType; -import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; -import static org.assertj.core.api.Assumptions.assumeThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; - -/** - * Tests for {@link NimbusOpaqueTokenIntrospector} - */ -public class NimbusOpaqueTokenIntrospectorTests { - - private static final String INTROSPECTION_URL = "https://server.example.com"; - - private static final String CLIENT_ID = "client"; - - private static final String CLIENT_SECRET = "secret"; - - // @formatter:off - private static final String ACTIVE_RESPONSE = "{\n" - + " \"active\": true,\n" - + " \"client_id\": \"l238j323ds-23ij4\",\n" - + " \"username\": \"jdoe\",\n" - + " \"scope\": \"read write dolphin\",\n" - + " \"sub\": \"Z5O3upPC88QrAjx00dis\",\n" - + " \"aud\": \"https://protected.example.net/resource\",\n" - + " \"iss\": \"https://server.example.com/\",\n" - + " \"exp\": 1419356238,\n" - + " \"iat\": 1419350238,\n" - + " \"extension_field\": \"twenty-seven\"\n" - + " }"; - // @formatter:on - - // @formatter:off - private static final String INACTIVE_RESPONSE = "{\n" - + " \"active\": false\n" - + " }"; - // @formatter:on - - // @formatter:off - private static final String INVALID_RESPONSE = "{\n" - + " \"client_id\": \"l238j323ds-23ij4\",\n" - + " \"username\": \"jdoe\",\n" - + " \"scope\": \"read write dolphin\",\n" - + " \"sub\": \"Z5O3upPC88QrAjx00dis\",\n" - + " \"aud\": \"https://protected.example.net/resource\",\n" - + " \"iss\": \"https://server.example.com/\",\n" - + " \"exp\": 1419356238,\n" - + " \"iat\": 1419350238,\n" - + " \"extension_field\": \"twenty-seven\"\n" - + " }"; - // @formatter:on - - // @formatter:off - private static final String MALFORMED_ISSUER_RESPONSE = "{\n" - + " \"active\" : \"true\",\n" - + " \"iss\" : \"badissuer\"\n" - + " }"; - // @formatter:on - - // @formatter:off - private static final String MALFORMED_SCOPE_RESPONSE = "{\n" - + " \"active\": true,\n" - + " \"client_id\": \"l238j323ds-23ij4\",\n" - + " \"username\": \"jdoe\",\n" - + " \"scope\": [ \"read\", \"write\", \"dolphin\" ],\n" - + " \"sub\": \"Z5O3upPC88QrAjx00dis\",\n" - + " \"aud\": \"https://protected.example.net/resource\",\n" - + " \"iss\": \"https://server.example.com/\",\n" - + " \"exp\": 1419356238,\n" - + " \"iat\": 1419350238,\n" - + " \"extension_field\": \"twenty-seven\"\n" - + " }"; - // @formatter:on - - private static final ResponseEntity ACTIVE = response(ACTIVE_RESPONSE); - - private static final ResponseEntity INACTIVE = response(INACTIVE_RESPONSE); - - private static final ResponseEntity INVALID = response(INVALID_RESPONSE); - - private static final ResponseEntity MALFORMED_ISSUER = response(MALFORMED_ISSUER_RESPONSE); - - private static final ResponseEntity MALFORMED_SCOPE = response(MALFORMED_SCOPE_RESPONSE); - - @Test - public void introspectWhenActiveTokenThenOk() throws Exception { - try (MockWebServer server = new MockWebServer()) { - server.setDispatcher(requiresAuth(CLIENT_ID, CLIENT_SECRET, ACTIVE_RESPONSE)); - String introspectUri = server.url("/introspect").toString(); - OpaqueTokenIntrospector introspectionClient = new NimbusOpaqueTokenIntrospector(introspectUri, CLIENT_ID, - CLIENT_SECRET); - OAuth2AuthenticatedPrincipal authority = introspectionClient.introspect("token"); - // @formatter:off - assertThat(authority.getAttributes()) - .isNotNull() - .containsEntry(OAuth2TokenIntrospectionClaimNames.ACTIVE, true) - .containsEntry(OAuth2TokenIntrospectionClaimNames.AUD, - Arrays.asList("https://protected.example.net/resource")) - .containsEntry(OAuth2TokenIntrospectionClaimNames.CLIENT_ID, "l238j323ds-23ij4") - .containsEntry(OAuth2TokenIntrospectionClaimNames.EXP, Instant.ofEpochSecond(1419356238)) - .containsEntry(OAuth2TokenIntrospectionClaimNames.ISS, "https://server.example.com/") - .containsEntry(OAuth2TokenIntrospectionClaimNames.SCOPE, Arrays.asList("read", "write", "dolphin")) - .containsEntry(OAuth2TokenIntrospectionClaimNames.SUB, "Z5O3upPC88QrAjx00dis") - .containsEntry(OAuth2TokenIntrospectionClaimNames.USERNAME, "jdoe") - .containsEntry("extension_field", "twenty-seven"); - // @formatter:on - } - } - - @Test - public void introspectWhenBadClientCredentialsThenError() throws IOException { - try (MockWebServer server = new MockWebServer()) { - server.setDispatcher(requiresAuth(CLIENT_ID, CLIENT_SECRET, ACTIVE_RESPONSE)); - String introspectUri = server.url("/introspect").toString(); - OpaqueTokenIntrospector introspectionClient = new NimbusOpaqueTokenIntrospector(introspectUri, CLIENT_ID, - "wrong"); - assertThatExceptionOfType(OAuth2IntrospectionException.class) - .isThrownBy(() -> introspectionClient.introspect("token")); - } - } - - @Test - public void introspectWhenInactiveTokenThenInvalidToken() { - RestOperations restOperations = mock(RestOperations.class); - OpaqueTokenIntrospector introspectionClient = new NimbusOpaqueTokenIntrospector(INTROSPECTION_URL, - restOperations); - given(restOperations.exchange(any(RequestEntity.class), eq(String.class))).willReturn(INACTIVE); - // @formatter:off - assertThatExceptionOfType(OAuth2IntrospectionException.class) - .isThrownBy(() -> introspectionClient.introspect("token")) - .withMessage("Provided token isn't active"); - // @formatter:on - } - - @Test - public void introspectWhenActiveTokenThenParsesValuesInResponse() { - Map introspectedValues = new HashMap<>(); - introspectedValues.put(OAuth2TokenIntrospectionClaimNames.ACTIVE, true); - introspectedValues.put(OAuth2TokenIntrospectionClaimNames.AUD, Arrays.asList("aud")); - introspectedValues.put(OAuth2TokenIntrospectionClaimNames.NBF, 29348723984L); - RestOperations restOperations = mock(RestOperations.class); - OpaqueTokenIntrospector introspectionClient = new NimbusOpaqueTokenIntrospector(INTROSPECTION_URL, - restOperations); - given(restOperations.exchange(any(RequestEntity.class), eq(String.class))) - .willReturn(response(new JSONObject(introspectedValues).toJSONString())); - OAuth2AuthenticatedPrincipal authority = introspectionClient.introspect("token"); - // @formatter:off - assertThat(authority.getAttributes()) - .isNotNull() - .containsEntry(OAuth2TokenIntrospectionClaimNames.ACTIVE, true) - .containsEntry(OAuth2TokenIntrospectionClaimNames.AUD, Arrays.asList("aud")) - .containsEntry(OAuth2TokenIntrospectionClaimNames.NBF, Instant.ofEpochSecond(29348723984L)) - .doesNotContainKey(OAuth2TokenIntrospectionClaimNames.CLIENT_ID) - .doesNotContainKey(OAuth2TokenIntrospectionClaimNames.SCOPE); - // @formatter:on - } - - @Test - public void introspectWhenIntrospectionEndpointThrowsExceptionThenInvalidToken() { - RestOperations restOperations = mock(RestOperations.class); - OpaqueTokenIntrospector introspectionClient = new NimbusOpaqueTokenIntrospector(INTROSPECTION_URL, - restOperations); - given(restOperations.exchange(any(RequestEntity.class), eq(String.class))) - .willThrow(new IllegalStateException("server was unresponsive")); - // @formatter:off - assertThatExceptionOfType(OAuth2IntrospectionException.class) - .isThrownBy(() -> introspectionClient.introspect("token")) - .withMessage("server was unresponsive"); - // @formatter:on - } - - @Test - public void introspectWhenIntrospectionEndpointReturnsMalformedResponseThenInvalidToken() { - RestOperations restOperations = mock(RestOperations.class); - OpaqueTokenIntrospector introspectionClient = new NimbusOpaqueTokenIntrospector(INTROSPECTION_URL, - restOperations); - given(restOperations.exchange(any(RequestEntity.class), eq(String.class))).willReturn(response("malformed")); - assertThatExceptionOfType(OAuth2IntrospectionException.class) - .isThrownBy(() -> introspectionClient.introspect("token")); - } - - @Test - public void introspectWhenIntrospectionTokenReturnsInvalidResponseThenInvalidToken() { - RestOperations restOperations = mock(RestOperations.class); - OpaqueTokenIntrospector introspectionClient = new NimbusOpaqueTokenIntrospector(INTROSPECTION_URL, - restOperations); - given(restOperations.exchange(any(RequestEntity.class), eq(String.class))).willReturn(INVALID); - assertThatExceptionOfType(OAuth2IntrospectionException.class) - .isThrownBy(() -> introspectionClient.introspect("token")); - } - - @Test - public void introspectWhenIntrospectionTokenReturnsMalformedIssuerResponseThenInvalidToken() { - RestOperations restOperations = mock(RestOperations.class); - OpaqueTokenIntrospector introspectionClient = new NimbusOpaqueTokenIntrospector(INTROSPECTION_URL, - restOperations); - given(restOperations.exchange(any(RequestEntity.class), eq(String.class))).willReturn(MALFORMED_ISSUER); - assertThatExceptionOfType(OAuth2IntrospectionException.class) - .isThrownBy(() -> introspectionClient.introspect("token")); - } - - // gh-7563 - @Test - public void introspectWhenIntrospectionTokenReturnsMalformedScopeThenEmptyAuthorities() { - RestOperations restOperations = mock(RestOperations.class); - OpaqueTokenIntrospector introspectionClient = new NimbusOpaqueTokenIntrospector(INTROSPECTION_URL, - restOperations); - given(restOperations.exchange(any(RequestEntity.class), eq(String.class))).willReturn(MALFORMED_SCOPE); - OAuth2AuthenticatedPrincipal principal = introspectionClient.introspect("token"); - assertThat(principal.getAuthorities()).isEmpty(); - JSONArray scope = principal.getAttribute("scope"); - assertThat(scope).containsExactly("read", "write", "dolphin"); - } - - @Test - public void constructorWhenIntrospectionUriIsNullThenIllegalArgumentException() { - assertThatIllegalArgumentException() - .isThrownBy(() -> new NimbusOpaqueTokenIntrospector(null, CLIENT_ID, CLIENT_SECRET)); - } - - @Test - public void constructorWhenClientIdIsNullThenIllegalArgumentException() { - assertThatIllegalArgumentException() - .isThrownBy(() -> new NimbusOpaqueTokenIntrospector(INTROSPECTION_URL, null, CLIENT_SECRET)); - } - - @Test - public void constructorWhenClientSecretIsNullThenIllegalArgumentException() { - assertThatIllegalArgumentException() - .isThrownBy(() -> new NimbusOpaqueTokenIntrospector(INTROSPECTION_URL, CLIENT_ID, null)); - } - - @Test - public void constructorWhenRestOperationsIsNullThenIllegalArgumentException() { - assertThatIllegalArgumentException() - .isThrownBy(() -> new NimbusOpaqueTokenIntrospector(INTROSPECTION_URL, null)); - } - - @Test - public void setRequestEntityConverterWhenConverterIsNullThenExceptionIsThrown() { - RestOperations restOperations = mock(RestOperations.class); - NimbusOpaqueTokenIntrospector introspectionClient = new NimbusOpaqueTokenIntrospector(INTROSPECTION_URL, - restOperations); - assertThatExceptionOfType(IllegalArgumentException.class) - .isThrownBy(() -> introspectionClient.setRequestEntityConverter(null)); - } - - @SuppressWarnings("unchecked") - @Test - public void setRequestEntityConverterWhenNonNullConverterGivenThenConverterUsed() { - RestOperations restOperations = mock(RestOperations.class); - Converter> requestEntityConverter = mock(Converter.class); - RequestEntity requestEntity = mock(RequestEntity.class); - String tokenToIntrospect = "some token"; - given(requestEntityConverter.convert(tokenToIntrospect)).willReturn(requestEntity); - given(restOperations.exchange(requestEntity, String.class)).willReturn(ACTIVE); - NimbusOpaqueTokenIntrospector introspectionClient = new NimbusOpaqueTokenIntrospector(INTROSPECTION_URL, - restOperations); - introspectionClient.setRequestEntityConverter(requestEntityConverter); - introspectionClient.introspect(tokenToIntrospect); - verify(requestEntityConverter).convert(tokenToIntrospect); - } - - @Test - public void handleMissingContentType() { - RestOperations restOperations = mock(RestOperations.class); - ResponseEntity stubResponse = ResponseEntity.ok(ACTIVE_RESPONSE); - given(restOperations.exchange(any(RequestEntity.class), eq(String.class))).willReturn(stubResponse); - OpaqueTokenIntrospector introspectionClient = new NimbusOpaqueTokenIntrospector(INTROSPECTION_URL, - restOperations); - - // Protect against potential regressions where a default content type might be - // added by default. - assumeThat(stubResponse.getHeaders().getContentType()).isNull(); - - assertThatExceptionOfType(OAuth2IntrospectionException.class) - .isThrownBy(() -> introspectionClient.introspect("sometokenhere")); - } - - @ParameterizedTest(name = "{displayName} when Content-Type={0}") - @ValueSource(strings = { MediaType.APPLICATION_CBOR_VALUE, MediaType.TEXT_MARKDOWN_VALUE, - MediaType.APPLICATION_XML_VALUE, MediaType.APPLICATION_OCTET_STREAM_VALUE }) - public void handleNonJsonContentType(String type) { - RestOperations restOperations = mock(RestOperations.class); - ResponseEntity stubResponse = ResponseEntity.ok() - .contentType(MediaType.parseMediaType(type)) - .body(ACTIVE_RESPONSE); - given(restOperations.exchange(any(RequestEntity.class), eq(String.class))).willReturn(stubResponse); - OpaqueTokenIntrospector introspectionClient = new NimbusOpaqueTokenIntrospector(INTROSPECTION_URL, - restOperations); - - assertThatExceptionOfType(OAuth2IntrospectionException.class) - .isThrownBy(() -> introspectionClient.introspect("sometokenhere")); - } - - private static ResponseEntity response(String content) { - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - return new ResponseEntity<>(content, headers, HttpStatus.OK); - } - - private static Dispatcher requiresAuth(String username, String password, String response) { - return new Dispatcher() { - @Override - public MockResponse dispatch(RecordedRequest request) { - String authorization = request.getHeader(HttpHeaders.AUTHORIZATION); - // @formatter:off - return Optional.ofNullable(authorization) - .filter((a) -> isAuthorized(authorization, username, password)) - .map((a) -> ok(response)) - .orElse(unauthorized()); - // @formatter:on - } - }; - } - - private static boolean isAuthorized(String authorization, String username, String password) { - String[] values = new String(Base64.getDecoder().decode(authorization.substring(6))).split(":"); - return username.equals(values[0]) && password.equals(values[1]); - } - - private static MockResponse ok(String response) { - // @formatter:off - return new MockResponse().setBody(response) - .setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE); - // @formatter:on - } - - private static MockResponse unauthorized() { - return new MockResponse().setResponseCode(401); - } - -} diff --git a/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/introspection/NimbusReactiveOpaqueTokenIntrospectorTests.java b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/introspection/NimbusReactiveOpaqueTokenIntrospectorTests.java deleted file mode 100644 index 0b8dd246cd5..00000000000 --- a/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/introspection/NimbusReactiveOpaqueTokenIntrospectorTests.java +++ /dev/null @@ -1,331 +0,0 @@ -/* - * Copyright 2002-2021 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.introspection; - -import java.io.IOException; -import java.time.Instant; -import java.util.Arrays; -import java.util.Base64; -import java.util.HashMap; -import java.util.Map; -import java.util.Optional; -import java.util.function.Function; - -import net.minidev.json.JSONObject; -import okhttp3.mockwebserver.Dispatcher; -import okhttp3.mockwebserver.MockResponse; -import okhttp3.mockwebserver.MockWebServer; -import okhttp3.mockwebserver.RecordedRequest; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; -import reactor.core.publisher.Mono; - -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.security.oauth2.core.OAuth2AuthenticatedPrincipal; -import org.springframework.security.oauth2.core.OAuth2TokenIntrospectionClaimNames; -import org.springframework.web.reactive.function.client.ClientResponse; -import org.springframework.web.reactive.function.client.WebClient; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatExceptionOfType; -import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.spy; - -/** - * Tests for {@link NimbusReactiveOpaqueTokenIntrospector} - */ -public class NimbusReactiveOpaqueTokenIntrospectorTests { - - private static final String INTROSPECTION_URL = "https://server.example.com"; - - private static final String CLIENT_ID = "client"; - - private static final String CLIENT_SECRET = "secret"; - - // @formatter:off - private static final String ACTIVE_RESPONSE = "{\n" - + " \"active\": true,\n" - + " \"client_id\": \"l238j323ds-23ij4\",\n" - + " \"username\": \"jdoe\",\n" - + " \"scope\": \"read write dolphin\",\n" - + " \"sub\": \"Z5O3upPC88QrAjx00dis\",\n" - + " \"aud\": \"https://protected.example.net/resource\",\n" - + " \"iss\": \"https://server.example.com/\",\n" - + " \"exp\": 1419356238,\n" - + " \"iat\": 1419350238,\n" - + " \"extension_field\": \"twenty-seven\"\n" - + " }"; - // @formatter:on - - // @formatter:off - private static final String INACTIVE_RESPONSE = "{\n" - + " \"active\": false\n" - + " }"; - // @formatter:on - - // @formatter:off - private static final String INVALID_RESPONSE = "{\n" - + " \"client_id\": \"l238j323ds-23ij4\",\n" - + " \"username\": \"jdoe\",\n" - + " \"scope\": \"read write dolphin\",\n" - + " \"sub\": \"Z5O3upPC88QrAjx00dis\",\n" - + " \"aud\": \"https://protected.example.net/resource\",\n" - + " \"iss\": \"https://server.example.com/\",\n" - + " \"exp\": 1419356238,\n" - + " \"iat\": 1419350238,\n" - + " \"extension_field\": \"twenty-seven\"\n" - + " }"; - // @formatter:on - - // @formatter:off - private static final String MALFORMED_ISSUER_RESPONSE = "{\n" - + " \"active\" : \"true\",\n" - + " \"iss\" : \"badissuer\"\n" - + " }"; - // @formatter:on - - @Test - public void authenticateWhenActiveTokenThenOk() throws Exception { - try (MockWebServer server = new MockWebServer()) { - server.setDispatcher(requiresAuth(CLIENT_ID, CLIENT_SECRET, ACTIVE_RESPONSE)); - String introspectUri = server.url("/introspect").toString(); - NimbusReactiveOpaqueTokenIntrospector introspectionClient = new NimbusReactiveOpaqueTokenIntrospector( - introspectUri, CLIENT_ID, CLIENT_SECRET); - OAuth2AuthenticatedPrincipal authority = introspectionClient.introspect("token").block(); - // @formatter:off - assertThat(authority.getAttributes()) - .isNotNull() - .containsEntry(OAuth2TokenIntrospectionClaimNames.ACTIVE, true) - .containsEntry(OAuth2TokenIntrospectionClaimNames.AUD, - Arrays.asList("https://protected.example.net/resource")) - .containsEntry(OAuth2TokenIntrospectionClaimNames.CLIENT_ID, "l238j323ds-23ij4") - .containsEntry(OAuth2TokenIntrospectionClaimNames.EXP, Instant.ofEpochSecond(1419356238)) - .containsEntry(OAuth2TokenIntrospectionClaimNames.ISS, "https://server.example.com/") - .containsEntry(OAuth2TokenIntrospectionClaimNames.SCOPE, Arrays.asList("read", "write", "dolphin")) - .containsEntry(OAuth2TokenIntrospectionClaimNames.SUB, "Z5O3upPC88QrAjx00dis") - .containsEntry(OAuth2TokenIntrospectionClaimNames.USERNAME, "jdoe") - .containsEntry("extension_field", "twenty-seven"); - // @formatter:on - } - } - - @Test - public void authenticateWhenBadClientCredentialsThenAuthenticationException() throws IOException { - try (MockWebServer server = new MockWebServer()) { - server.setDispatcher(requiresAuth(CLIENT_ID, CLIENT_SECRET, ACTIVE_RESPONSE)); - String introspectUri = server.url("/introspect").toString(); - NimbusReactiveOpaqueTokenIntrospector introspectionClient = new NimbusReactiveOpaqueTokenIntrospector( - introspectUri, CLIENT_ID, "wrong"); - assertThatExceptionOfType(OAuth2IntrospectionException.class) - .isThrownBy(() -> introspectionClient.introspect("token").block()); - - } - } - - @Test - public void authenticateWhenInactiveTokenThenInvalidToken() { - WebClient webClient = mockResponse(INACTIVE_RESPONSE); - NimbusReactiveOpaqueTokenIntrospector introspectionClient = new NimbusReactiveOpaqueTokenIntrospector( - INTROSPECTION_URL, webClient); - assertThatExceptionOfType(BadOpaqueTokenException.class) - .isThrownBy(() -> introspectionClient.introspect("token").block()) - .withMessage("Provided token isn't active"); - } - - @Test - public void authenticateWhenActiveTokenThenParsesValuesInResponse() { - Map introspectedValues = new HashMap<>(); - introspectedValues.put(OAuth2TokenIntrospectionClaimNames.ACTIVE, true); - introspectedValues.put(OAuth2TokenIntrospectionClaimNames.AUD, Arrays.asList("aud")); - introspectedValues.put(OAuth2TokenIntrospectionClaimNames.NBF, 29348723984L); - WebClient webClient = mockResponse(new JSONObject(introspectedValues).toJSONString()); - NimbusReactiveOpaqueTokenIntrospector introspectionClient = new NimbusReactiveOpaqueTokenIntrospector( - INTROSPECTION_URL, webClient); - OAuth2AuthenticatedPrincipal authority = introspectionClient.introspect("token").block(); - // @formatter:off - assertThat(authority.getAttributes()) - .isNotNull() - .containsEntry(OAuth2TokenIntrospectionClaimNames.ACTIVE, true) - .containsEntry(OAuth2TokenIntrospectionClaimNames.AUD, Arrays.asList("aud")) - .containsEntry(OAuth2TokenIntrospectionClaimNames.NBF, Instant.ofEpochSecond(29348723984L)) - .doesNotContainKey(OAuth2TokenIntrospectionClaimNames.CLIENT_ID) - .doesNotContainKey(OAuth2TokenIntrospectionClaimNames.SCOPE); - // @formatter:on - } - - @Test - public void authenticateWhenIntrospectionEndpointThrowsExceptionThenInvalidToken() { - WebClient webClient = mockResponse(new IllegalStateException("server was unresponsive")); - NimbusReactiveOpaqueTokenIntrospector introspectionClient = new NimbusReactiveOpaqueTokenIntrospector( - INTROSPECTION_URL, webClient); - // @formatter:off - assertThatExceptionOfType(OAuth2IntrospectionException.class) - .isThrownBy(() -> introspectionClient.introspect("token").block()) - .withMessage("server was unresponsive"); - // @formatter:on - } - - @Test - public void authenticateWhenIntrospectionEndpointReturnsMalformedResponseThenInvalidToken() { - WebClient webClient = mockResponse("malformed"); - NimbusReactiveOpaqueTokenIntrospector introspectionClient = new NimbusReactiveOpaqueTokenIntrospector( - INTROSPECTION_URL, webClient); - assertThatExceptionOfType(OAuth2IntrospectionException.class) - .isThrownBy(() -> introspectionClient.introspect("token").block()); - } - - @Test - public void authenticateWhenIntrospectionTokenReturnsInvalidResponseThenInvalidToken() { - WebClient webClient = mockResponse(INVALID_RESPONSE); - NimbusReactiveOpaqueTokenIntrospector introspectionClient = new NimbusReactiveOpaqueTokenIntrospector( - INTROSPECTION_URL, webClient); - // @formatter:off - assertThatExceptionOfType(OAuth2IntrospectionException.class) - .isThrownBy(() -> introspectionClient.introspect("token").block()); - // @formatter:on - } - - @Test - public void authenticateWhenIntrospectionTokenReturnsMalformedIssuerResponseThenInvalidToken() { - WebClient webClient = mockResponse(MALFORMED_ISSUER_RESPONSE); - NimbusReactiveOpaqueTokenIntrospector introspectionClient = new NimbusReactiveOpaqueTokenIntrospector( - INTROSPECTION_URL, webClient); - assertThatExceptionOfType(OAuth2IntrospectionException.class) - .isThrownBy(() -> introspectionClient.introspect("token").block()); - } - - @Test - public void constructorWhenIntrospectionUriIsEmptyThenIllegalArgumentException() { - assertThatIllegalArgumentException() - .isThrownBy(() -> new NimbusReactiveOpaqueTokenIntrospector("", CLIENT_ID, CLIENT_SECRET)); - } - - @Test - public void constructorWhenClientIdIsEmptyThenIllegalArgumentException() { - assertThatIllegalArgumentException() - .isThrownBy(() -> new NimbusReactiveOpaqueTokenIntrospector(INTROSPECTION_URL, "", CLIENT_SECRET)); - } - - @Test - public void constructorWhenClientSecretIsNullThenIllegalArgumentException() { - assertThatIllegalArgumentException() - .isThrownBy(() -> new NimbusReactiveOpaqueTokenIntrospector(INTROSPECTION_URL, CLIENT_ID, null)); - } - - @Test - public void constructorWhenRestOperationsIsNullThenIllegalArgumentException() { - assertThatIllegalArgumentException() - .isThrownBy(() -> new NimbusReactiveOpaqueTokenIntrospector(INTROSPECTION_URL, null)); - } - - @Test - public void handleMissingContentType() { - WebClient client = mockResponse(ACTIVE_RESPONSE, null); - - ReactiveOpaqueTokenIntrospector introspectionClient = new NimbusReactiveOpaqueTokenIntrospector( - INTROSPECTION_URL, client); - - assertThatExceptionOfType(OAuth2IntrospectionException.class) - .isThrownBy(() -> introspectionClient.introspect("sometokenhere").block()); - } - - @ParameterizedTest(name = "{displayName} when Content-Type={0}") - @ValueSource(strings = { MediaType.APPLICATION_CBOR_VALUE, MediaType.TEXT_MARKDOWN_VALUE, - MediaType.APPLICATION_XML_VALUE, MediaType.APPLICATION_OCTET_STREAM_VALUE }) - public void handleNonJsonContentType(String type) { - WebClient client = mockResponse(ACTIVE_RESPONSE, type); - - ReactiveOpaqueTokenIntrospector introspectionClient = new NimbusReactiveOpaqueTokenIntrospector( - INTROSPECTION_URL, client); - - assertThatExceptionOfType(OAuth2IntrospectionException.class) - .isThrownBy(() -> introspectionClient.introspect("sometokenhere").block()); - } - - private WebClient mockResponse(String response) { - return mockResponse(response, MediaType.APPLICATION_JSON_VALUE); - } - - private WebClient mockResponse(String response, String mediaType) { - WebClient.ResponseSpec responseSpec = mock(WebClient.ResponseSpec.class); - WebClient real = WebClient.builder().build(); - WebClient.RequestBodyUriSpec spec = spy(real.post()); - WebClient webClient = spy(WebClient.class); - given(webClient.post()).willReturn(spec); - ClientResponse clientResponse = mock(ClientResponse.class); - given(clientResponse.statusCode()).willReturn(HttpStatus.OK); - given(clientResponse.bodyToMono(String.class)).willReturn(Mono.just(response)); - ClientResponse.Headers headers = mock(ClientResponse.Headers.class); - given(headers.contentType()).willReturn(Optional.ofNullable(mediaType).map(MediaType::parseMediaType)); - given(clientResponse.headers()).willReturn(headers); - given(responseSpec.bodyToMono(ClientResponse.class)).willReturn(Mono.just(clientResponse)); - given(spec.exchangeToMono(any())).willAnswer((invocation) -> { - Object[] args = invocation.getArguments(); - Function> fn = (Function>) args[0]; - return fn.apply(clientResponse); - }); - given(spec.retrieve()).willReturn(responseSpec); - return webClient; - } - - private WebClient mockResponse(Throwable ex) { - WebClient real = WebClient.builder().build(); - WebClient.RequestBodyUriSpec spec = spy(real.post()); - WebClient webClient = spy(WebClient.class); - given(webClient.post()).willReturn(spec); - given(spec.exchangeToMono(any())).willReturn(Mono.error(ex)); - return webClient; - } - - private static Dispatcher requiresAuth(String username, String password, String response) { - return new Dispatcher() { - @Override - public MockResponse dispatch(RecordedRequest request) { - String authorization = request.getHeader(HttpHeaders.AUTHORIZATION); - // @formatter:off - return Optional.ofNullable(authorization) - .filter((a) -> isAuthorized(authorization, username, password)) - .map((a) -> ok(response)) - .orElse(unauthorized()); - // @formatter:on - } - }; - } - - private static boolean isAuthorized(String authorization, String username, String password) { - String[] values = new String(Base64.getDecoder().decode(authorization.substring(6))).split(":"); - return username.equals(values[0]) && password.equals(values[1]); - } - - private static MockResponse ok(String response) { - // @formatter:off - return new MockResponse().setBody(response) - .setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE); - // @formatter:on - } - - private static MockResponse unauthorized() { - return new MockResponse().setResponseCode(401); - } - -}