From b1195e7789521845752b91677285f9ff5eeeb6b0 Mon Sep 17 00:00:00 2001 From: Josh Cummings Date: Wed, 20 Mar 2019 13:36:47 -0600 Subject: [PATCH] Opaque Token Intermediate Type Introducing OAuth2TokenIntrospectionClient and also ReactiveOAuth2TokenIntrospectionClient as configuration points. The DSL looks in the application context for these types in the same way it looks for JwtDecoder and ReactiveJwtDecoder, and exposes similar configuration methods. Fixes: gh-6632 --- .../OAuth2ResourceServerConfigurer.java | 44 ++- .../config/web/server/ServerHttpSecurity.java | 40 ++- .../OAuth2ResourceServerConfigurerTests.java | 141 ++++++++- ...2ResourceServerConfigurerTests-Active.json | 6 + ...eServerConfigurerTests-ActiveNoScopes.json | 5 + ...esourceServerConfigurerTests-Inactive.json | 3 + ...h2IntrospectionAuthenticationProvider.java | 210 +++---------- ...Auth2IntrospectionAuthenticationToken.java | 2 +- ...spectionReactiveAuthenticationManager.java | 192 ++--------- .../NimbusOAuth2TokenIntrospectionClient.java | 206 ++++++++++++ ...eactiveOAuth2TokenIntrospectionClient.java | 189 +++++++++++ .../OAuth2IntrospectionClaimNames.java | 4 +- .../OAuth2IntrospectionException.java | 33 ++ .../OAuth2TokenIntrospectionClient.java | 43 +++ ...eactiveOAuth2TokenIntrospectionClient.java | 45 +++ .../resource/introspection/package-info.java | 20 ++ ...rospectionAuthenticationProviderTests.java | 294 ++++------------- ...IntrospectionAuthenticationTokenTests.java | 6 +- ...ionReactiveAuthenticationManagerTests.java | 297 ++++-------------- ...usOAuth2TokenIntrospectionClientTests.java | 289 +++++++++++++++++ ...veOAuth2TokenIntrospectionClientTests.java | 288 +++++++++++++++++ ...uth2TokenIntrospectionClientResponses.java | 59 ++++ ...h2ResourceServerSecurityConfiguration.java | 6 +- 23 files changed, 1577 insertions(+), 845 deletions(-) create mode 100644 config/src/test/resources/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests-Active.json create mode 100644 config/src/test/resources/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests-ActiveNoScopes.json create mode 100644 config/src/test/resources/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests-Inactive.json create mode 100644 oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/NimbusOAuth2TokenIntrospectionClient.java create mode 100644 oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/NimbusReactiveOAuth2TokenIntrospectionClient.java rename oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/{authentication => introspection}/OAuth2IntrospectionClaimNames.java (94%) create mode 100644 oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/OAuth2IntrospectionException.java create mode 100644 oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/OAuth2TokenIntrospectionClient.java create mode 100644 oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/ReactiveOAuth2TokenIntrospectionClient.java create mode 100644 oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/package-info.java create mode 100644 oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/introspection/NimbusOAuth2TokenIntrospectionClientTests.java create mode 100644 oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/introspection/NimbusReactiveOAuth2TokenIntrospectionClientTests.java create mode 100644 oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/introspection/TestOAuth2TokenIntrospectionClientResponses.java diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurer.java index 93cb4e9a98..4939247b6c 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurer.java @@ -16,6 +16,7 @@ package org.springframework.security.config.annotation.web.configurers.oauth2.server.resource; +import java.util.function.Supplier; import javax.servlet.http.HttpServletRequest; import org.springframework.context.ApplicationContext; @@ -23,7 +24,6 @@ import org.springframework.core.convert.converter.Converter; import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.AuthenticationManagerResolver; -import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.config.annotation.web.HttpSecurityBuilder; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer; @@ -36,6 +36,8 @@ import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationProvider; import org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionAuthenticationProvider; +import org.springframework.security.oauth2.server.resource.introspection.NimbusOAuth2TokenIntrospectionClient; +import org.springframework.security.oauth2.server.resource.introspection.OAuth2TokenIntrospectionClient; import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint; import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationFilter; import org.springframework.security.oauth2.server.resource.web.BearerTokenResolver; @@ -179,7 +181,7 @@ public final class OAuth2ResourceServerConfigurer introspectionClient; + + OpaqueTokenConfigurer(ApplicationContext context) { + this.context = context; + } public OpaqueTokenConfigurer introspectionUri(String introspectionUri) { Assert.notNull(introspectionUri, "introspectionUri cannot be null"); this.introspectionUri = introspectionUri; + this.introspectionClient = () -> + new NimbusOAuth2TokenIntrospectionClient(this.introspectionUri, this.clientId, this.clientSecret); return this; } public OpaqueTokenConfigurer introspectionClientCredentials(String clientId, String clientSecret) { Assert.notNull(clientId, "clientId cannot be null"); Assert.notNull(clientSecret, "clientSecret cannot be null"); - this.introspectionClientId = clientId; - this.introspectionClientSecret = clientSecret; + this.clientId = clientId; + this.clientSecret = clientSecret; + this.introspectionClient = () -> + new NimbusOAuth2TokenIntrospectionClient(this.introspectionUri, this.clientId, this.clientSecret); return this; } - AuthenticationProvider getProvider() { - return new OAuth2IntrospectionAuthenticationProvider(this.introspectionUri, - this.introspectionClientId, this.introspectionClientSecret); + public OpaqueTokenConfigurer introspectionClient(OAuth2TokenIntrospectionClient introspectionClient) { + Assert.notNull(introspectionClient, "introspectionClient cannot be null"); + this.introspectionClient = () -> introspectionClient; + return this; + } + + OAuth2TokenIntrospectionClient getIntrospectionClient() { + if (this.introspectionClient != null) { + return this.introspectionClient.get(); + } + return this.context.getBean(OAuth2TokenIntrospectionClient.class); } } diff --git a/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java b/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java index bc6b5c7feb..d30aa1763e 100644 --- a/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java +++ b/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java @@ -30,9 +30,8 @@ import java.util.Map; import java.util.Optional; import java.util.UUID; import java.util.function.Function; +import java.util.function.Supplier; -import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; -import org.springframework.security.oauth2.client.endpoint.ReactiveOAuth2AccessTokenResponseClient; import reactor.core.publisher.Mono; import reactor.util.context.Context; @@ -61,6 +60,8 @@ import org.springframework.security.oauth2.client.InMemoryReactiveOAuth2Authoriz import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientService; import org.springframework.security.oauth2.client.authentication.OAuth2AuthorizationCodeReactiveAuthenticationManager; import org.springframework.security.oauth2.client.authentication.OAuth2LoginReactiveAuthenticationManager; +import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; +import org.springframework.security.oauth2.client.endpoint.ReactiveOAuth2AccessTokenResponseClient; import org.springframework.security.oauth2.client.endpoint.WebClientReactiveAuthorizationCodeTokenResponseClient; import org.springframework.security.oauth2.client.oidc.authentication.OidcAuthorizationCodeReactiveAuthenticationManager; import org.springframework.security.oauth2.client.oidc.userinfo.OidcReactiveOAuth2UserService; @@ -88,6 +89,8 @@ import org.springframework.security.oauth2.server.resource.authentication.JwtAut import org.springframework.security.oauth2.server.resource.authentication.JwtReactiveAuthenticationManager; import org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionReactiveAuthenticationManager; import org.springframework.security.oauth2.server.resource.authentication.ReactiveJwtAuthenticationConverterAdapter; +import org.springframework.security.oauth2.server.resource.introspection.NimbusReactiveOAuth2TokenIntrospectionClient; +import org.springframework.security.oauth2.server.resource.introspection.ReactiveOAuth2TokenIntrospectionClient; import org.springframework.security.oauth2.server.resource.web.access.server.BearerTokenServerAccessDeniedHandler; import org.springframework.security.oauth2.server.resource.web.server.BearerTokenServerAuthenticationEntryPoint; import org.springframework.security.oauth2.server.resource.web.server.ServerBearerTokenAuthenticationConverter; @@ -1364,8 +1367,9 @@ public class ServerHttpSecurity { */ public class OpaqueTokenSpec { private String introspectionUri; - private String introspectionClientId; - private String introspectionClientSecret; + private String clientId; + private String clientSecret; + private Supplier introspectionClient; /** * Configures the URI of the Introspection endpoint @@ -1375,6 +1379,9 @@ public class ServerHttpSecurity { public OpaqueTokenSpec introspectionUri(String introspectionUri) { Assert.hasText(introspectionUri, "introspectionUri cannot be empty"); this.introspectionUri = introspectionUri; + this.introspectionClient = () -> + new NimbusReactiveOAuth2TokenIntrospectionClient( + this.introspectionUri, this.clientId, this.clientSecret); return this; } @@ -1387,8 +1394,17 @@ public class ServerHttpSecurity { public OpaqueTokenSpec introspectionClientCredentials(String clientId, String clientSecret) { Assert.hasText(clientId, "clientId cannot be empty"); Assert.notNull(clientSecret, "clientSecret cannot be null"); - this.introspectionClientId = clientId; - this.introspectionClientSecret = clientSecret; + this.clientId = clientId; + this.clientSecret = clientSecret; + this.introspectionClient = () -> + new NimbusReactiveOAuth2TokenIntrospectionClient( + this.introspectionUri, this.clientId, this.clientSecret); + return this; + } + + public OpaqueTokenSpec introspectionClient(ReactiveOAuth2TokenIntrospectionClient introspectionClient) { + Assert.notNull(introspectionClient, "introspectionClient cannot be null"); + this.introspectionClient = () -> introspectionClient; return this; } @@ -1401,8 +1417,14 @@ public class ServerHttpSecurity { } protected ReactiveAuthenticationManager getAuthenticationManager() { - return new OAuth2IntrospectionReactiveAuthenticationManager( - this.introspectionUri, this.introspectionClientId, this.introspectionClientSecret); + return new OAuth2IntrospectionReactiveAuthenticationManager(getIntrospectionClient()); + } + + protected ReactiveOAuth2TokenIntrospectionClient getIntrospectionClient() { + if (this.introspectionClient != null) { + return this.introspectionClient.get(); + } + return getBean(ReactiveOAuth2TokenIntrospectionClient.class); } protected void configure(ServerHttpSecurity http) { @@ -1412,6 +1434,8 @@ public class ServerHttpSecurity { oauth2.setAuthenticationFailureHandler(new ServerAuthenticationEntryPointFailureHandler(entryPoint)); http.addFilterAt(oauth2, SecurityWebFiltersOrder.AUTHENTICATION); } + + private OpaqueTokenSpec() {} } public ServerHttpSecurity and() { 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 f6734af2d2..c36712b9b1 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 @@ -51,6 +51,7 @@ import org.springframework.context.ApplicationContext; import org.springframework.context.EnvironmentAware; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.support.GenericApplicationContext; import org.springframework.core.convert.converter.Converter; import org.springframework.core.env.ConfigurableEnvironment; import org.springframework.core.env.Environment; @@ -78,6 +79,8 @@ import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.oauth2.core.OAuth2Error; import org.springframework.security.oauth2.core.OAuth2TokenValidator; import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; +import org.springframework.security.oauth2.server.resource.introspection.NimbusOAuth2TokenIntrospectionClient; +import org.springframework.security.oauth2.server.resource.introspection.OAuth2TokenIntrospectionClient; import org.springframework.security.oauth2.jose.jws.JwsAlgorithms; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.jwt.JwtClaimNames; @@ -147,6 +150,10 @@ public class OAuth2ResourceServerConfigurerTests { private static final JwtAuthenticationToken JWT_AUTHENTICATION_TOKEN = new JwtAuthenticationToken(JWT, Collections.emptyList()); + private static final String INTROSPECTION_URI = "https://idp.example.com"; + private static final String CLIENT_ID = "client-id"; + private static final String CLIENT_SECRET = "client-secret"; + @Autowired(required = false) MockMvc mvc; @@ -1008,6 +1015,90 @@ public class OAuth2ResourceServerConfigurerTests { .andExpect(invalidTokenHeader("algorithm")); } + // -- opaque + + + @Test + public void getWhenIntrospectingThenOk() throws Exception { + this.spring.register(RestOperationsConfig.class, OpaqueTokenConfig.class, BasicController.class).autowire(); + mockRestOperations(json("Active")); + + this.mvc.perform(get("/authenticated") + .with(bearerToken("token"))) + .andExpect(status().isOk()) + .andExpect(content().string("test-subject")); + } + + @Test + public void getWhenIntrospectionFailsThenUnauthorized() throws Exception { + this.spring.register(RestOperationsConfig.class, OpaqueTokenConfig.class).autowire(); + mockRestOperations(json("Inactive")); + + this.mvc.perform(get("/") + .with(bearerToken("token"))) + .andExpect(status().isUnauthorized()) + .andExpect(header().string(HttpHeaders.WWW_AUTHENTICATE, + containsString("Provided token [token] isn't active"))); + } + + @Test + public void getWhenIntrospectionLacksScopeThenForbidden() throws Exception { + this.spring.register(RestOperationsConfig.class, OpaqueTokenConfig.class).autowire(); + mockRestOperations(json("ActiveNoScopes")); + + this.mvc.perform(get("/requires-read-scope") + .with(bearerToken("token"))) + .andExpect(status().isForbidden()) + .andExpect(header().string(HttpHeaders.WWW_AUTHENTICATE, containsString("scope"))); + } + + @Test + public void configureWhenOnlyIntrospectionUrlThenException() throws Exception { + assertThatCode(() -> this.spring.register(OpaqueTokenHalfConfiguredConfig.class).autowire()) + .isInstanceOf(BeanCreationException.class); + } + + @Test + public void getIntrospectionClientWhenConfiguredWithClientAndIntrospectionUriThenLastOneWins() { + ApplicationContext context = mock(ApplicationContext.class); + + OAuth2ResourceServerConfigurer.OpaqueTokenConfigurer opaqueTokenConfigurer = + new OAuth2ResourceServerConfigurer(context).opaqueToken(); + + OAuth2TokenIntrospectionClient client = mock(OAuth2TokenIntrospectionClient.class); + + opaqueTokenConfigurer.introspectionUri(INTROSPECTION_URI); + opaqueTokenConfigurer.introspectionClientCredentials(CLIENT_ID, CLIENT_SECRET); + opaqueTokenConfigurer.introspectionClient(client); + + assertThat(opaqueTokenConfigurer.getIntrospectionClient()).isEqualTo(client); + + opaqueTokenConfigurer = + new OAuth2ResourceServerConfigurer(context).opaqueToken(); + + opaqueTokenConfigurer.introspectionClient(client); + opaqueTokenConfigurer.introspectionUri(INTROSPECTION_URI); + opaqueTokenConfigurer.introspectionClientCredentials(CLIENT_ID, CLIENT_SECRET); + + assertThat(opaqueTokenConfigurer.getIntrospectionClient()) + .isInstanceOf(NimbusOAuth2TokenIntrospectionClient.class); + + } + + @Test + public void getIntrospectionClientWhenDslAndBeanWiredThenDslTakesPrecedence() { + GenericApplicationContext context = new GenericApplicationContext(); + registerMockBean(context, "introspectionClientOne", OAuth2TokenIntrospectionClient.class); + registerMockBean(context, "introspectionClientTwo", OAuth2TokenIntrospectionClient.class); + + OAuth2ResourceServerConfigurer.OpaqueTokenConfigurer opaqueToken = + new OAuth2ResourceServerConfigurer(context).opaqueToken(); + opaqueToken.introspectionUri(INTROSPECTION_URI); + opaqueToken.introspectionClientCredentials(CLIENT_ID, CLIENT_SECRET); + + assertThat(opaqueToken.getIntrospectionClient()).isNotNull(); + } + // -- In combination with other authentication providers @Test @@ -1628,6 +1719,22 @@ public class OAuth2ResourceServerConfigurerTests { } } + @EnableWebSecurity + static class OpaqueTokenConfig extends WebSecurityConfigurerAdapter { + @Override + protected void configure(HttpSecurity http) throws Exception { + // @formatter:off + http + .authorizeRequests() + .antMatchers("/requires-read-scope").hasAuthority("SCOPE_message:read") + .anyRequest().authenticated() + .and() + .oauth2ResourceServer() + .opaqueToken(); + // @formatter:on + } + } + @EnableWebSecurity static class OpaqueAndJwtConfig extends WebSecurityConfigurerAdapter { @Override @@ -1641,6 +1748,22 @@ public class OAuth2ResourceServerConfigurerTests { } } + @EnableWebSecurity + static class OpaqueTokenHalfConfiguredConfig extends WebSecurityConfigurerAdapter { + @Override + protected void configure(HttpSecurity http) throws Exception { + // @formatter:off + http + .authorizeRequests() + .anyRequest().authenticated() + .and() + .oauth2ResourceServer() + .opaqueToken() + .introspectionUri("https://idp.example.com"); // missing credentials + // @formatter:on + } + } + @Configuration static class JwtDecoderConfig { @Bean @@ -1740,6 +1863,15 @@ public class OAuth2ResourceServerConfigurerTests { return withJwkSetUri("https://example.org/.well-known/jwks.json") .restOperations(this.rest).build(); } + + @Bean + NimbusOAuth2TokenIntrospectionClient tokenIntrospectionClient() { + return new NimbusOAuth2TokenIntrospectionClient("https://example.org/introspect", this.rest); + } + } + + private static void registerMockBean(GenericApplicationContext context, String name, Class clazz) { + context.registerBean(name, clazz, () -> mock(clazz)); } private static class BearerTokenRequestPostProcessor implements RequestPostProcessor { @@ -1815,8 +1947,15 @@ public class OAuth2ResourceServerConfigurerTests { private void mockRestOperations(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); when(rest.exchange(any(RequestEntity.class), eq(String.class))) - .thenReturn(new ResponseEntity<>(response, HttpStatus.OK)); + .thenReturn(entity); + } + + private String json(String name) throws IOException { + return resource(name + ".json"); } private String jwks(String name) throws IOException { diff --git a/config/src/test/resources/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests-Active.json b/config/src/test/resources/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests-Active.json new file mode 100644 index 0000000000..3e3dfac24b --- /dev/null +++ b/config/src/test/resources/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests-Active.json @@ -0,0 +1,6 @@ +{ + "active" : true, + "sub": "test-subject", + "scope": "message:read", + "exp": 4683883211 +} diff --git a/config/src/test/resources/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests-ActiveNoScopes.json b/config/src/test/resources/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests-ActiveNoScopes.json new file mode 100644 index 0000000000..e1e2438e2b --- /dev/null +++ b/config/src/test/resources/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests-ActiveNoScopes.json @@ -0,0 +1,5 @@ +{ + "active" : true, + "sub": "test-subject", + "exp": 4683883211 +} diff --git a/config/src/test/resources/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests-Inactive.json b/config/src/test/resources/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests-Inactive.json new file mode 100644 index 0000000000..1e01d475d8 --- /dev/null +++ b/config/src/test/resources/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests-Inactive.json @@ -0,0 +1,3 @@ +{ + "active" : false +} diff --git a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionAuthenticationProvider.java b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionAuthenticationProvider.java index eb7032f555..37aff3720e 100644 --- a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionAuthenticationProvider.java +++ b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionAuthenticationProvider.java @@ -15,28 +15,14 @@ */ package org.springframework.security.oauth2.server.resource.authentication; -import java.net.URI; -import java.net.URL; import java.time.Instant; import java.util.Collection; import java.util.Collections; -import java.util.List; import java.util.Map; import java.util.Optional; import java.util.stream.Collectors; -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.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; -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.authentication.AbstractAuthenticationToken; import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.core.Authentication; @@ -45,21 +31,16 @@ import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.oauth2.core.OAuth2AccessToken; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionException; +import org.springframework.security.oauth2.server.resource.introspection.OAuth2TokenIntrospectionClient; import org.springframework.security.oauth2.server.resource.BearerTokenAuthenticationToken; import org.springframework.security.oauth2.server.resource.BearerTokenError; 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; -import static org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames.AUDIENCE; -import static org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames.CLIENT_ID; -import static org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames.EXPIRES_AT; -import static org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames.ISSUED_AT; -import static org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames.ISSUER; -import static org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames.NOT_BEFORE; -import static org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames.SCOPE; +import static org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames.EXPIRES_AT; +import static org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames.ISSUED_AT; +import static org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames.SCOPE; /** * An {@link AuthenticationProvider} implementation for opaque @@ -84,39 +65,19 @@ import static org.springframework.security.oauth2.server.resource.authentication * @see AuthenticationProvider */ public final class OAuth2IntrospectionAuthenticationProvider implements AuthenticationProvider { - private URI introspectionUri; - private RestOperations restOperations; + private static final BearerTokenError DEFAULT_INVALID_TOKEN = + invalidToken("An error occurred while attempting to introspect the token: Invalid token"); + + private OAuth2TokenIntrospectionClient introspectionClient; /** * Creates a {@code OAuth2IntrospectionAuthenticationProvider} 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 + * @param introspectionClient The {@link OAuth2TokenIntrospectionClient} to use */ - public OAuth2IntrospectionAuthenticationProvider(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.introspectionUri = URI.create(introspectionUri); - RestTemplate restTemplate = new RestTemplate(); - restTemplate.getInterceptors().add(new BasicAuthenticationInterceptor(clientId, clientSecret)); - this.restOperations = restTemplate; - } - - /** - * Creates a {@code OAuth2IntrospectionAuthenticationProvider} with the provided parameters - * - * @param introspectionUri The introspection endpoint uri - * @param restOperations The client for performing the introspection request - */ - public OAuth2IntrospectionAuthenticationProvider(String introspectionUri, RestOperations restOperations) { - Assert.notNull(introspectionUri, "introspectionUri cannot be null"); - Assert.notNull(restOperations, "restOperations cannot be null"); - - this.introspectionUri = URI.create(introspectionUri); - this.restOperations = restOperations; + public OAuth2IntrospectionAuthenticationProvider(OAuth2TokenIntrospectionClient introspectionClient) { + Assert.notNull(introspectionClient, "introspectionClient cannot be null"); + this.introspectionClient = introspectionClient; } /** @@ -133,20 +94,17 @@ public final class OAuth2IntrospectionAuthenticationProvider implements Authenti if (!(authentication instanceof BearerTokenAuthenticationToken)) { return null; } - - // introspect BearerTokenAuthenticationToken bearer = (BearerTokenAuthenticationToken) authentication; - TokenIntrospectionSuccessResponse response = introspect(bearer.getToken()); - Map claims = convertClaimsSet(response); - Instant iat = (Instant) claims.get(ISSUED_AT); - Instant exp = (Instant) claims.get(EXPIRES_AT); - // construct token - OAuth2AccessToken token = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, - bearer.getToken(), iat, exp); - Collection authorities = extractAuthorities(claims); - AbstractAuthenticationToken result = - new OAuth2IntrospectionAuthenticationToken(token, claims, authorities); + Map claims; + try { + claims = this.introspectionClient.introspect(bearer.getToken()); + } catch (OAuth2IntrospectionException failed) { + OAuth2Error invalidToken = invalidToken(failed.getMessage()); + throw new OAuth2AuthenticationException(invalidToken); + } + + AbstractAuthenticationToken result = convert(bearer.getToken(), claims); result.setDetails(bearer.getDetails()); return result; } @@ -159,103 +117,13 @@ public final class OAuth2IntrospectionAuthenticationProvider implements Authenti return BearerTokenAuthenticationToken.class.isAssignableFrom(authentication); } - private TokenIntrospectionSuccessResponse introspect(String token) { - return Optional.of(token) - .map(this::buildRequest) - .map(this::makeRequest) - .map(this::adaptToNimbusResponse) - .map(this::parseNimbusResponse) - .map(this::castToNimbusSuccess) - // relying solely on the authorization server to validate this token (not checking 'exp', for example) - .filter(TokenIntrospectionSuccessResponse::isActive) - .orElseThrow(() -> new OAuth2AuthenticationException( - invalidToken("Provided token [" + token + "] isn't active"))); - } - - private RequestEntity> buildRequest(String token) { - HttpHeaders headers = requestHeaders(); - MultiValueMap body = requestBody(token); - return new RequestEntity<>(body, headers, HttpMethod.POST, this.introspectionUri); - } - - private HttpHeaders requestHeaders() { - HttpHeaders headers = new HttpHeaders(); - headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON_UTF8)); - return headers; - } - - private MultiValueMap requestBody(String token) { - MultiValueMap body = new LinkedMultiValueMap<>(); - body.add("token", token); - return body; - } - - private ResponseEntity makeRequest(RequestEntity requestEntity) { - try { - return this.restOperations.exchange(requestEntity, String.class); - } catch (Exception ex) { - throw new OAuth2AuthenticationException( - invalidToken(ex.getMessage()), ex); - } - } - - private HTTPResponse adaptToNimbusResponse(ResponseEntity responseEntity) { - HTTPResponse response = new HTTPResponse(responseEntity.getStatusCodeValue()); - response.setHeader(HttpHeaders.CONTENT_TYPE, responseEntity.getHeaders().getContentType().toString()); - response.setContent(responseEntity.getBody()); - - if (response.getStatusCode() != HTTPResponse.SC_OK) { - throw new OAuth2AuthenticationException( - invalidToken("Introspection endpoint responded with " + response.getStatusCode())); - } - return response; - } - - private TokenIntrospectionResponse parseNimbusResponse(HTTPResponse response) { - try { - return TokenIntrospectionResponse.parse(response); - } catch (Exception ex) { - throw new OAuth2AuthenticationException( - invalidToken(ex.getMessage()), ex); - } - } - - private TokenIntrospectionSuccessResponse castToNimbusSuccess(TokenIntrospectionResponse introspectionResponse) { - if (!introspectionResponse.indicatesSuccess()) { - throw new OAuth2AuthenticationException(invalidToken("Token introspection failed")); - } - return (TokenIntrospectionSuccessResponse) introspectionResponse; - } - - private Map convertClaimsSet(TokenIntrospectionSuccessResponse response) { - Map claims = response.toJSONObject(); - if (response.getAudience() != null) { - List audience = response.getAudience().stream() - .map(Audience::getValue).collect(Collectors.toList()); - claims.put(AUDIENCE, Collections.unmodifiableList(audience)); - } - if (response.getClientID() != null) { - claims.put(CLIENT_ID, response.getClientID().getValue()); - } - if (response.getExpirationTime() != null) { - Instant exp = response.getExpirationTime().toInstant(); - claims.put(EXPIRES_AT, exp); - } - if (response.getIssueTime() != null) { - Instant iat = response.getIssueTime().toInstant(); - claims.put(ISSUED_AT, iat); - } - if (response.getIssuer() != null) { - claims.put(ISSUER, issuer(response.getIssuer().getValue())); - } - if (response.getNotBeforeTime() != null) { - claims.put(NOT_BEFORE, response.getNotBeforeTime().toInstant()); - } - if (response.getScope() != null) { - claims.put(SCOPE, Collections.unmodifiableList(response.getScope().toStringList())); - } - - return claims; + private AbstractAuthenticationToken convert(String token, Map claims) { + Instant iat = (Instant) claims.get(ISSUED_AT); + Instant exp = (Instant) claims.get(EXPIRES_AT); + OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, + token, iat, exp); + Collection authorities = extractAuthorities(claims); + return new OAuth2IntrospectionAuthenticationToken(accessToken, claims, authorities); } private Collection extractAuthorities(Map claims) { @@ -266,18 +134,14 @@ public final class OAuth2IntrospectionAuthenticationProvider implements Authenti .collect(Collectors.toList()); } - private URL issuer(String uri) { + private static BearerTokenError invalidToken(String message) { try { - return new URL(uri); - } catch (Exception ex) { - throw new OAuth2AuthenticationException( - invalidToken("Invalid " + ISSUER + " value: " + uri), ex); + return new BearerTokenError("invalid_token", + HttpStatus.UNAUTHORIZED, message, + "https://tools.ietf.org/html/rfc7662#section-2.2"); + } catch (IllegalArgumentException malformed) { + // some third-party library error messages are not suitable for RFC 6750's error message charset + return DEFAULT_INVALID_TOKEN; } } - - private static BearerTokenError invalidToken(String message) { - return new BearerTokenError("invalid_token", - HttpStatus.UNAUTHORIZED, message, - "https://tools.ietf.org/html/rfc7662#section-2.2"); - } } diff --git a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionAuthenticationToken.java b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionAuthenticationToken.java index 9bccd64997..433bba5e13 100644 --- a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionAuthenticationToken.java +++ b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionAuthenticationToken.java @@ -25,7 +25,7 @@ import org.springframework.security.core.SpringSecurityCoreVersion; import org.springframework.security.oauth2.core.OAuth2AccessToken; import org.springframework.util.Assert; -import static org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames.SUBJECT; +import static org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames.SUBJECT; /** * An {@link org.springframework.security.core.Authentication} token that represents a successful authentication as diff --git a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionReactiveAuthenticationManager.java b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionReactiveAuthenticationManager.java index 755989d6e4..a48d8407aa 100644 --- a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionReactiveAuthenticationManager.java +++ b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionReactiveAuthenticationManager.java @@ -16,27 +16,16 @@ package org.springframework.security.oauth2.server.resource.authentication; -import java.net.URI; -import java.net.URL; -import java.nio.charset.StandardCharsets; import java.time.Instant; -import java.util.Base64; import java.util.Collection; import java.util.Collections; -import java.util.List; import java.util.Map; import java.util.Optional; import java.util.stream.Collectors; -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 reactor.core.publisher.Mono; -import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; import org.springframework.security.authentication.ReactiveAuthenticationManager; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; @@ -44,21 +33,15 @@ import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.oauth2.core.OAuth2AccessToken; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionException; +import org.springframework.security.oauth2.server.resource.introspection.ReactiveOAuth2TokenIntrospectionClient; import org.springframework.security.oauth2.server.resource.BearerTokenAuthenticationToken; import org.springframework.security.oauth2.server.resource.BearerTokenError; import org.springframework.util.Assert; -import org.springframework.util.StringUtils; -import org.springframework.web.reactive.function.BodyInserters; -import org.springframework.web.reactive.function.client.ClientResponse; -import org.springframework.web.reactive.function.client.WebClient; -import static org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames.AUDIENCE; -import static org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames.CLIENT_ID; -import static org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames.EXPIRES_AT; -import static org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames.ISSUED_AT; -import static org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames.ISSUER; -import static org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames.NOT_BEFORE; -import static org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames.SCOPE; +import static org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames.EXPIRES_AT; +import static org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames.ISSUED_AT; +import static org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames.SCOPE; /** * An {@link ReactiveAuthenticationManager} implementation for opaque @@ -83,51 +66,19 @@ import static org.springframework.security.oauth2.server.resource.authentication * @see ReactiveAuthenticationManager */ public class OAuth2IntrospectionReactiveAuthenticationManager implements ReactiveAuthenticationManager { - private URI introspectionUri; - private WebClient webClient; + private static final BearerTokenError DEFAULT_INVALID_TOKEN = + invalidToken("An error occurred while attempting to introspect the token: Invalid token"); + + private ReactiveOAuth2TokenIntrospectionClient introspectionClient; /** * Creates a {@code OAuth2IntrospectionReactiveAuthenticationManager} 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 + * @param introspectionClient The {@link ReactiveOAuth2TokenIntrospectionClient} to use */ - public OAuth2IntrospectionReactiveAuthenticationManager(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() - .defaultHeader(HttpHeaders.AUTHORIZATION, basicHeaderValue(clientId, clientSecret)) - .build(); - } - - /** - * Creates a {@code OAuth2IntrospectionReactiveAuthenticationManager} with the provided parameters - * - * @param introspectionUri The introspection endpoint uri - * @param webClient The client for performing the introspection request - */ - public OAuth2IntrospectionReactiveAuthenticationManager(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; - } - - private static String basicHeaderValue(String clientId, String clientSecret) { - String headerValue = clientId + ":"; - if (StringUtils.hasText(clientSecret)) { - headerValue += clientSecret; - } - return "Basic " + Base64.getEncoder().encodeToString(headerValue.getBytes(StandardCharsets.UTF_8)); + public OAuth2IntrospectionReactiveAuthenticationManager(ReactiveOAuth2TokenIntrospectionClient introspectionClient) { + Assert.notNull(introspectionClient, "introspectionClient cannot be null"); + this.introspectionClient = introspectionClient; } @Override @@ -141,9 +92,8 @@ public class OAuth2IntrospectionReactiveAuthenticationManager implements Reactiv } private Mono authenticate(String token) { - return introspect(token) - .map(response -> { - Map claims = convertClaimsSet(response); + return this.introspectionClient.introspect(token) + .map(claims -> { Instant iat = (Instant) claims.get(ISSUED_AT); Instant exp = (Instant) claims.get(EXPIRES_AT); @@ -152,91 +102,8 @@ public class OAuth2IntrospectionReactiveAuthenticationManager implements Reactiv new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, token, iat, exp); Collection authorities = extractAuthorities(claims); return new OAuth2IntrospectionAuthenticationToken(accessToken, claims, authorities); - }); - } - - private Mono introspect(String token) { - return Mono.just(token) - .flatMap(this::makeRequest) - .flatMap(this::adaptToNimbusResponse) - .map(this::parseNimbusResponse) - .map(this::castToNimbusSuccess) - .doOnNext(response -> validate(token, response)) - .onErrorMap(e -> !(e instanceof OAuth2AuthenticationException), this::onError); - } - - private Mono makeRequest(String token) { - return this.webClient.post() - .uri(this.introspectionUri) - .header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_UTF8_VALUE) - .body(BodyInserters.fromFormData("token", token)) - .exchange(); - } - - private Mono adaptToNimbusResponse(ClientResponse responseEntity) { - HTTPResponse response = new HTTPResponse(responseEntity.rawStatusCode()); - response.setHeader(HttpHeaders.CONTENT_TYPE, responseEntity.headers().contentType().get().toString()); - if (response.getStatusCode() != HTTPResponse.SC_OK) { - throw new OAuth2AuthenticationException( - invalidToken("Introspection endpoint responded with " + response.getStatusCode())); - } - 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 OAuth2AuthenticationException( - invalidToken(ex.getMessage()), ex); - } - } - - private TokenIntrospectionSuccessResponse castToNimbusSuccess(TokenIntrospectionResponse introspectionResponse) { - if (!introspectionResponse.indicatesSuccess()) { - throw new OAuth2AuthenticationException(invalidToken("Token introspection failed")); - } - 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()) { - throw new OAuth2AuthenticationException(invalidToken("Provided token [" + token + "] isn't active")); - } - } - - private Map convertClaimsSet(TokenIntrospectionSuccessResponse response) { - Map claims = response.toJSONObject(); - if (response.getAudience() != null) { - List audience = response.getAudience().stream() - .map(Audience::getValue).collect(Collectors.toList()); - claims.put(AUDIENCE, Collections.unmodifiableList(audience)); - } - if (response.getClientID() != null) { - claims.put(CLIENT_ID, response.getClientID().getValue()); - } - if (response.getExpirationTime() != null) { - Instant exp = response.getExpirationTime().toInstant(); - claims.put(EXPIRES_AT, exp); - } - if (response.getIssueTime() != null) { - Instant iat = response.getIssueTime().toInstant(); - claims.put(ISSUED_AT, iat); - } - if (response.getIssuer() != null) { - claims.put(ISSUER, issuer(response.getIssuer().getValue())); - } - if (response.getNotBeforeTime() != null) { - claims.put(NOT_BEFORE, response.getNotBeforeTime().toInstant()); - } - if (response.getScope() != null) { - claims.put(SCOPE, Collections.unmodifiableList(response.getScope().toStringList())); - } - - return claims; + }) + .onErrorMap(OAuth2IntrospectionException.class, this::onError); } private Collection extractAuthorities(Map claims) { @@ -247,24 +114,19 @@ public class OAuth2IntrospectionReactiveAuthenticationManager implements Reactiv .collect(Collectors.toList()); } - private URL issuer(String uri) { + private static BearerTokenError invalidToken(String message) { try { - return new URL(uri); - } catch (Exception ex) { - throw new OAuth2AuthenticationException( - invalidToken("Invalid " + ISSUER + " value: " + uri), ex); + return new BearerTokenError("invalid_token", + HttpStatus.UNAUTHORIZED, message, + "https://tools.ietf.org/html/rfc7662#section-2.2"); + } catch (IllegalArgumentException e) { + // some third-party library error messages are not suitable for RFC 6750's error message charset + return DEFAULT_INVALID_TOKEN; } } - private static BearerTokenError invalidToken(String message) { - return new BearerTokenError("invalid_token", - HttpStatus.UNAUTHORIZED, message, - "https://tools.ietf.org/html/rfc7662#section-2.2"); - } - - - private OAuth2AuthenticationException onError(Throwable e) { - OAuth2Error invalidToken = invalidToken(e.getMessage()); - return new OAuth2AuthenticationException(invalidToken, e.getMessage()); + private OAuth2AuthenticationException onError(OAuth2IntrospectionException e) { + OAuth2Error invalidRequest = invalidToken(e.getMessage()); + return new OAuth2AuthenticationException(invalidRequest, e.getMessage()); } } diff --git a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/NimbusOAuth2TokenIntrospectionClient.java b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/NimbusOAuth2TokenIntrospectionClient.java new file mode 100644 index 0000000000..7c4c437a7f --- /dev/null +++ b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/NimbusOAuth2TokenIntrospectionClient.java @@ -0,0 +1,206 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.oauth2.server.resource.introspection; + +import java.net.URI; +import java.net.URL; +import java.time.Instant; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +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.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.util.Assert; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestOperations; +import org.springframework.web.client.RestTemplate; + +import static org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames.AUDIENCE; +import static org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames.CLIENT_ID; +import static org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames.EXPIRES_AT; +import static org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames.ISSUED_AT; +import static org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames.ISSUER; +import static org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames.NOT_BEFORE; +import static org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames.SCOPE; + +/** + * A Nimbus implementation of {@link OAuth2TokenIntrospectionClient}. + * + * @author Josh Cummings + * @since 5.2 + */ +public class NimbusOAuth2TokenIntrospectionClient implements OAuth2TokenIntrospectionClient { + private URI introspectionUri; + private RestOperations restOperations; + + /** + * Creates a {@code OAuth2IntrospectionAuthenticationProvider} 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 NimbusOAuth2TokenIntrospectionClient(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.introspectionUri = URI.create(introspectionUri); + RestTemplate restTemplate = new RestTemplate(); + restTemplate.getInterceptors().add(new BasicAuthenticationInterceptor(clientId, clientSecret)); + this.restOperations = restTemplate; + } + + /** + * Creates a {@code OAuth2IntrospectionAuthenticationProvider} 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 NimbusOAuth2TokenIntrospectionClient(String introspectionUri, RestOperations restOperations) { + Assert.notNull(introspectionUri, "introspectionUri cannot be null"); + Assert.notNull(restOperations, "restOperations cannot be null"); + + this.introspectionUri = URI.create(introspectionUri); + this.restOperations = restOperations; + } + + /** + * {@inheritDoc} + */ + @Override + public Map introspect(String token) { + TokenIntrospectionSuccessResponse response = Optional.of(token) + .map(this::buildRequest) + .map(this::makeRequest) + .map(this::adaptToNimbusResponse) + .map(this::parseNimbusResponse) + .map(this::castToNimbusSuccess) + // relying solely on the authorization server to validate this token (not checking 'exp', for example) + .filter(TokenIntrospectionSuccessResponse::isActive) + .orElseThrow(() -> new OAuth2IntrospectionException("Provided token [" + token + "] isn't active")); + return convertClaimsSet(response); + } + + private RequestEntity> buildRequest(String token) { + HttpHeaders headers = requestHeaders(); + MultiValueMap body = requestBody(token); + return new RequestEntity<>(body, headers, HttpMethod.POST, this.introspectionUri); + } + + private HttpHeaders requestHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON_UTF8)); + return headers; + } + + private MultiValueMap requestBody(String token) { + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("token", token); + return body; + } + + 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) { + HTTPResponse response = new HTTPResponse(responseEntity.getStatusCodeValue()); + response.setHeader(HttpHeaders.CONTENT_TYPE, responseEntity.getHeaders().getContentType().toString()); + response.setContent(responseEntity.getBody()); + + if (response.getStatusCode() != HTTPResponse.SC_OK) { + throw new OAuth2IntrospectionException( + "Introspection endpoint responded with " + 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()) { + throw new OAuth2IntrospectionException("Token introspection failed"); + } + return (TokenIntrospectionSuccessResponse) introspectionResponse; + } + + private Map convertClaimsSet(TokenIntrospectionSuccessResponse response) { + Map claims = response.toJSONObject(); + if (response.getAudience() != null) { + List audience = response.getAudience().stream() + .map(Audience::getValue).collect(Collectors.toList()); + claims.put(AUDIENCE, Collections.unmodifiableList(audience)); + } + if (response.getClientID() != null) { + claims.put(CLIENT_ID, response.getClientID().getValue()); + } + if (response.getExpirationTime() != null) { + Instant exp = response.getExpirationTime().toInstant(); + claims.put(EXPIRES_AT, exp); + } + if (response.getIssueTime() != null) { + Instant iat = response.getIssueTime().toInstant(); + claims.put(ISSUED_AT, iat); + } + if (response.getIssuer() != null) { + claims.put(ISSUER, issuer(response.getIssuer().getValue())); + } + if (response.getNotBeforeTime() != null) { + claims.put(NOT_BEFORE, response.getNotBeforeTime().toInstant()); + } + if (response.getScope() != null) { + claims.put(SCOPE, Collections.unmodifiableList(response.getScope().toStringList())); + } + + return claims; + } + + private URL issuer(String uri) { + try { + return new URL(uri); + } catch (Exception ex) { + throw new OAuth2IntrospectionException("Invalid " + ISSUER + " value: " + uri); + } + } +} diff --git a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/NimbusReactiveOAuth2TokenIntrospectionClient.java b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/NimbusReactiveOAuth2TokenIntrospectionClient.java new file mode 100644 index 0000000000..04ba6b9aba --- /dev/null +++ b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/NimbusReactiveOAuth2TokenIntrospectionClient.java @@ -0,0 +1,189 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.oauth2.server.resource.introspection; + +import java.net.URI; +import java.net.URL; +import java.time.Instant; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +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 reactor.core.publisher.Mono; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +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; + +import static org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames.AUDIENCE; +import static org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames.CLIENT_ID; +import static org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames.EXPIRES_AT; +import static org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames.ISSUED_AT; +import static org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames.ISSUER; +import static org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames.NOT_BEFORE; +import static org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames.SCOPE; + +/** + * A Nimbus implementation of {@link ReactiveOAuth2TokenIntrospectionClient} + * + * @author Josh Cummings + * @since 5.2 + */ +public class NimbusReactiveOAuth2TokenIntrospectionClient implements ReactiveOAuth2TokenIntrospectionClient { + private URI introspectionUri; + private WebClient webClient; + + /** + * Creates a {@code OAuth2IntrospectionReactiveAuthenticationManager} 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 NimbusReactiveOAuth2TokenIntrospectionClient(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 OAuth2IntrospectionReactiveAuthenticationManager} with the provided parameters + * + * @param introspectionUri The introspection endpoint uri + * @param webClient The client for performing the introspection request + */ + public NimbusReactiveOAuth2TokenIntrospectionClient(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; + } + + /** + * {@inheritDoc} + */ + @Override + public Mono> introspect(String token) { + return Mono.just(token) + .flatMap(this::makeRequest) + .flatMap(this::adaptToNimbusResponse) + .map(this::parseNimbusResponse) + .map(this::castToNimbusSuccess) + .doOnNext(response -> validate(token, response)) + .map(this::convertClaimsSet) + .onErrorMap(e -> !(e instanceof OAuth2IntrospectionException), this::onError); + } + + private Mono makeRequest(String token) { + return this.webClient.post() + .uri(this.introspectionUri) + .header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_UTF8_VALUE) + .body(BodyInserters.fromFormData("token", token)) + .exchange(); + } + + private Mono adaptToNimbusResponse(ClientResponse responseEntity) { + HTTPResponse response = new HTTPResponse(responseEntity.rawStatusCode()); + response.setHeader(HttpHeaders.CONTENT_TYPE, responseEntity.headers().contentType().get().toString()); + if (response.getStatusCode() != HTTPResponse.SC_OK) { + throw new OAuth2IntrospectionException( + "Introspection endpoint responded with " + response.getStatusCode()); + } + 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()) { + throw new OAuth2IntrospectionException("Token introspection failed"); + } + 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()) { + throw new OAuth2IntrospectionException("Provided token [" + token + "] isn't active"); + } + } + + private Map convertClaimsSet(TokenIntrospectionSuccessResponse response) { + Map claims = response.toJSONObject(); + if (response.getAudience() != null) { + List audience = response.getAudience().stream() + .map(Audience::getValue).collect(Collectors.toList()); + claims.put(AUDIENCE, Collections.unmodifiableList(audience)); + } + if (response.getClientID() != null) { + claims.put(CLIENT_ID, response.getClientID().getValue()); + } + if (response.getExpirationTime() != null) { + Instant exp = response.getExpirationTime().toInstant(); + claims.put(EXPIRES_AT, exp); + } + if (response.getIssueTime() != null) { + Instant iat = response.getIssueTime().toInstant(); + claims.put(ISSUED_AT, iat); + } + if (response.getIssuer() != null) { + claims.put(ISSUER, issuer(response.getIssuer().getValue())); + } + if (response.getNotBeforeTime() != null) { + claims.put(NOT_BEFORE, response.getNotBeforeTime().toInstant()); + } + if (response.getScope() != null) { + claims.put(SCOPE, Collections.unmodifiableList(response.getScope().toStringList())); + } + + return claims; + } + + private URL issuer(String uri) { + try { + return new URL(uri); + } catch (Exception ex) { + throw new OAuth2IntrospectionException("Invalid " + ISSUER + " value: " + uri); + } + } + + private OAuth2IntrospectionException onError(Throwable e) { + return new OAuth2IntrospectionException(e.getMessage(), e); + } +} diff --git a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionClaimNames.java b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/OAuth2IntrospectionClaimNames.java similarity index 94% rename from oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionClaimNames.java rename to oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/OAuth2IntrospectionClaimNames.java index 178f08f33e..d2f011d18a 100644 --- a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionClaimNames.java +++ b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/OAuth2IntrospectionClaimNames.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.springframework.security.oauth2.server.resource.authentication; +package org.springframework.security.oauth2.server.resource.introspection; /** * The names of the "Introspection Claims" defined by an @@ -22,7 +22,7 @@ package org.springframework.security.oauth2.server.resource.authentication; * @author Josh Cummings * @since 5.2 */ -interface OAuth2IntrospectionClaimNames { +public interface OAuth2IntrospectionClaimNames { /** * {@code active} - Indicator whether or not the token is currently active diff --git a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/OAuth2IntrospectionException.java b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/OAuth2IntrospectionException.java new file mode 100644 index 0000000000..ffd468fc54 --- /dev/null +++ b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/OAuth2IntrospectionException.java @@ -0,0 +1,33 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.oauth2.server.resource.introspection; + +/** + * Base exception for all OAuth 2.0 Introspection related errors + * + * @author Josh Cummings + * @since 5.2 + */ +public class OAuth2IntrospectionException extends RuntimeException { + public OAuth2IntrospectionException(String message) { + super(message); + } + + public OAuth2IntrospectionException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/OAuth2TokenIntrospectionClient.java b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/OAuth2TokenIntrospectionClient.java new file mode 100644 index 0000000000..6bf285a242 --- /dev/null +++ b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/OAuth2TokenIntrospectionClient.java @@ -0,0 +1,43 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.oauth2.server.resource.introspection; + +import java.util.Map; + +/** + * A client to an + * OAuth 2.0 Introspection Endpoint. + * + * Basically, this client is handy when a resource server authenticates opaque OAuth 2.0 tokens. + * It's also nice when a resource server simply can't decode tokens - whether the tokens are opaque or not - + * and would prefer to delegate that task to an authorization server. + * + * @author Josh Cummings + * @since 5.2 + */ +public interface OAuth2TokenIntrospectionClient { + + /** + * Request that the configured + * OAuth 2.0 Introspection Endpoint + * introspect the given token and return its associated attributes. + * + * @param token the token to introspect + * @return the token's attributes, including whether or not the token is active + */ + Map introspect(String token); +} diff --git a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/ReactiveOAuth2TokenIntrospectionClient.java b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/ReactiveOAuth2TokenIntrospectionClient.java new file mode 100644 index 0000000000..09919e0d8a --- /dev/null +++ b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/ReactiveOAuth2TokenIntrospectionClient.java @@ -0,0 +1,45 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.oauth2.server.resource.introspection; + +import java.util.Map; + +import reactor.core.publisher.Mono; + +/** + * A reactive client to an + * OAuth 2.0 Introspection Endpoint. + * + * Basically, this client is handy when a resource server authenticates opaque OAuth 2.0 tokens. + * It's also nice when a resource server simply can't decode tokens - whether the tokens are opaque or not - + * and would prefer to delegate that task to an authorization server. + * + * @author Josh Cummings + * @since 5.2 + */ +public interface ReactiveOAuth2TokenIntrospectionClient { + + /** + * Request that the configured + * OAuth 2.0 Introspection Endpoint + * introspect the given token and return its associated attributes. + * + * @param token the token to introspect + * @return the token's attributes, including whether or not the token is active + */ + Mono> introspect(String token); +} diff --git a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/package-info.java b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/package-info.java new file mode 100644 index 0000000000..bde6182d72 --- /dev/null +++ b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * OAuth 2.0 Introspection supporting classes and interfaces. + */ +package org.springframework.security.oauth2.server.resource.introspection; diff --git a/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionAuthenticationProviderTests.java b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionAuthenticationProviderTests.java index b041b3bdd5..f97bf77287 100644 --- a/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionAuthenticationProviderTests.java +++ b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionAuthenticationProviderTests.java @@ -15,45 +15,34 @@ */ package org.springframework.security.oauth2.server.resource.authentication; -import java.io.IOException; import java.net.URL; 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.JSONObject; -import okhttp3.mockwebserver.Dispatcher; -import okhttp3.mockwebserver.MockResponse; -import okhttp3.mockwebserver.MockWebServer; -import okhttp3.mockwebserver.RecordedRequest; import org.junit.Test; -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.core.Authentication; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames; +import org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionException; +import org.springframework.security.oauth2.server.resource.introspection.OAuth2TokenIntrospectionClient; import org.springframework.security.oauth2.server.resource.BearerTokenAuthenticationToken; -import org.springframework.web.client.RestOperations; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; -import static org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames.AUDIENCE; -import static org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames.EXPIRES_AT; -import static org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames.ISSUER; -import static org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames.NOT_BEFORE; -import static org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames.SCOPE; -import static org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames.SUBJECT; -import static org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames.USERNAME; +import static org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames.ACTIVE; +import static org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames.AUDIENCE; +import static org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames.EXPIRES_AT; +import static org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames.ISSUER; +import static org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames.NOT_BEFORE; +import static org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames.SCOPE; +import static org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames.SUBJECT; +import static org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames.USERNAME; +import static org.springframework.security.oauth2.server.resource.introspection.TestOAuth2TokenIntrospectionClientResponses.active; /** * Tests for {@link OAuth2IntrospectionAuthenticationProvider} @@ -62,121 +51,14 @@ import static org.springframework.security.oauth2.server.resource.authentication * @since 5.2 */ public class OAuth2IntrospectionAuthenticationProviderTests { - private static final String INTROSPECTION_URL = "https://server.example.com"; - private static final String CLIENT_ID = "client"; - private static final String CLIENT_SECRET = "secret"; - - 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" + - " }"; - - private static final String INACTIVE_RESPONSE = "{\n" + - " \"active\": false\n" + - " }"; - - 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" + - " }"; - - private static final String MALFORMED_ISSUER_RESPONSE = "{\n" + - " \"active\" : \"true\",\n" + - " \"iss\" : \"badissuer\"\n" + - " }"; - - 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); - @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(); - OAuth2IntrospectionAuthenticationProvider provider = - new OAuth2IntrospectionAuthenticationProvider(introspectUri, CLIENT_ID, CLIENT_SECRET); - - Authentication result = - provider.authenticate(new BearerTokenAuthenticationToken("token")); - - assertThat(result.getPrincipal()).isInstanceOf(Map.class); - - Map attributes = (Map) result.getPrincipal(); - assertThat(attributes) - .isNotNull() - .containsEntry(OAuth2IntrospectionClaimNames.ACTIVE, true) - .containsEntry(AUDIENCE, Arrays.asList("https://protected.example.net/resource")) - .containsEntry(OAuth2IntrospectionClaimNames.CLIENT_ID, "l238j323ds-23ij4") - .containsEntry(EXPIRES_AT, Instant.ofEpochSecond(1419356238)) - .containsEntry(ISSUER, new URL("https://server.example.com/")) - .containsEntry(SCOPE, Arrays.asList("read", "write", "dolphin")) - .containsEntry(SUBJECT, "Z5O3upPC88QrAjx00dis") - .containsEntry(USERNAME, "jdoe") - .containsEntry("extension_field", "twenty-seven"); - - assertThat(result.getAuthorities()).extracting("authority") - .containsExactly("SCOPE_read", "SCOPE_write", "SCOPE_dolphin"); - } - } - - @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(); - OAuth2IntrospectionAuthenticationProvider provider = - new OAuth2IntrospectionAuthenticationProvider(introspectUri, CLIENT_ID, "wrong"); - - assertThatCode(() -> provider.authenticate(new BearerTokenAuthenticationToken("token"))) - .isInstanceOf(OAuth2AuthenticationException.class); - } - } - - @Test - public void authenticateWhenInactiveTokenThenInvalidToken() { - RestOperations restOperations = mock(RestOperations.class); + Map claims = active(); + claims.put("extension_field", "twenty-seven"); + OAuth2TokenIntrospectionClient introspectionClient = mock(OAuth2TokenIntrospectionClient.class); + when(introspectionClient.introspect(any())).thenReturn(claims); OAuth2IntrospectionAuthenticationProvider provider = - new OAuth2IntrospectionAuthenticationProvider(INTROSPECTION_URL, restOperations); - when(restOperations.exchange(any(RequestEntity.class), eq(String.class))) - .thenReturn(INACTIVE); - - assertThatCode(() -> provider.authenticate(new BearerTokenAuthenticationToken("token"))) - .isInstanceOf(OAuth2AuthenticationException.class) - .extracting("error.errorCode") - .containsExactly("invalid_token"); - } - - @Test - public void authenticateWhenActiveTokenThenParsesValuesInResponse() { - Map introspectedValues = new HashMap<>(); - introspectedValues.put(OAuth2IntrospectionClaimNames.ACTIVE, true); - introspectedValues.put(AUDIENCE, Arrays.asList("aud")); - introspectedValues.put(NOT_BEFORE, 29348723984L); - - RestOperations restOperations = mock(RestOperations.class); - OAuth2IntrospectionAuthenticationProvider provider = - new OAuth2IntrospectionAuthenticationProvider(INTROSPECTION_URL, restOperations); - when(restOperations.exchange(any(RequestEntity.class), eq(String.class))) - .thenReturn(response(new JSONObject(introspectedValues).toJSONString())); + new OAuth2IntrospectionAuthenticationProvider(introspectionClient); Authentication result = provider.authenticate(new BearerTokenAuthenticationToken("token")); @@ -186,10 +68,37 @@ public class OAuth2IntrospectionAuthenticationProviderTests { Map attributes = (Map) result.getPrincipal(); assertThat(attributes) .isNotNull() - .containsEntry(OAuth2IntrospectionClaimNames.ACTIVE, true) - .containsEntry(AUDIENCE, Arrays.asList("aud")) + .containsEntry(ACTIVE, true) + .containsEntry(AUDIENCE, Arrays.asList("https://protected.example.net/resource")) + .containsEntry(OAuth2IntrospectionClaimNames.CLIENT_ID, "l238j323ds-23ij4") + .containsEntry(EXPIRES_AT, Instant.ofEpochSecond(1419356238)) + .containsEntry(ISSUER, new URL("https://server.example.com/")) .containsEntry(NOT_BEFORE, Instant.ofEpochSecond(29348723984L)) - .doesNotContainKey(OAuth2IntrospectionClaimNames.CLIENT_ID) + .containsEntry(SCOPE, Arrays.asList("read", "write", "dolphin")) + .containsEntry(SUBJECT, "Z5O3upPC88QrAjx00dis") + .containsEntry(USERNAME, "jdoe") + .containsEntry("extension_field", "twenty-seven"); + + assertThat(result.getAuthorities()).extracting("authority") + .containsExactly("SCOPE_read", "SCOPE_write", "SCOPE_dolphin"); + } + + @Test + public void authenticateWhenMissingScopeAttributeThenNoAuthorities() { + Map claims = active(); + claims.remove(SCOPE); + OAuth2TokenIntrospectionClient introspectionClient = mock(OAuth2TokenIntrospectionClient.class); + when(introspectionClient.introspect(any())).thenReturn(claims); + OAuth2IntrospectionAuthenticationProvider provider = + new OAuth2IntrospectionAuthenticationProvider(introspectionClient); + + Authentication result = + provider.authenticate(new BearerTokenAuthenticationToken("token")); + assertThat(result.getPrincipal()).isInstanceOf(Map.class); + + Map attributes = (Map) result.getPrincipal(); + assertThat(attributes) + .isNotNull() .doesNotContainKey(SCOPE); assertThat(result.getAuthorities()).isEmpty(); @@ -197,115 +106,20 @@ public class OAuth2IntrospectionAuthenticationProviderTests { @Test public void authenticateWhenIntrospectionEndpointThrowsExceptionThenInvalidToken() { - RestOperations restOperations = mock(RestOperations.class); + OAuth2TokenIntrospectionClient introspectionClient = mock(OAuth2TokenIntrospectionClient.class); + when(introspectionClient.introspect(any())).thenThrow(new OAuth2IntrospectionException("with \"invalid\" chars")); OAuth2IntrospectionAuthenticationProvider provider = - new OAuth2IntrospectionAuthenticationProvider(INTROSPECTION_URL, restOperations); - when(restOperations.exchange(any(RequestEntity.class), eq(String.class))) - .thenThrow(new IllegalStateException("server was unresponsive")); + new OAuth2IntrospectionAuthenticationProvider(introspectionClient); assertThatCode(() -> provider.authenticate(new BearerTokenAuthenticationToken("token"))) .isInstanceOf(OAuth2AuthenticationException.class) - .extracting("error.errorCode") - .containsExactly("invalid_token"); - } - - - @Test - public void authenticateWhenIntrospectionEndpointReturnsMalformedResponseThenInvalidToken() { - RestOperations restOperations = mock(RestOperations.class); - OAuth2IntrospectionAuthenticationProvider provider = - new OAuth2IntrospectionAuthenticationProvider(INTROSPECTION_URL, restOperations); - when(restOperations.exchange(any(RequestEntity.class), eq(String.class))) - .thenReturn(response("malformed")); - - assertThatCode(() -> provider.authenticate(new BearerTokenAuthenticationToken("token"))) - .isInstanceOf(OAuth2AuthenticationException.class) - .extracting("error.errorCode") - .containsExactly("invalid_token"); + .extracting("error.description") + .containsExactly("An error occurred while attempting to introspect the token: Invalid token"); } @Test - public void authenticateWhenIntrospectionTokenReturnsInvalidResponseThenInvalidToken() { - RestOperations restOperations = mock(RestOperations.class); - OAuth2IntrospectionAuthenticationProvider provider = - new OAuth2IntrospectionAuthenticationProvider(INTROSPECTION_URL, restOperations); - when(restOperations.exchange(any(RequestEntity.class), eq(String.class))) - .thenReturn(INVALID); - - assertThatCode(() -> provider.authenticate(new BearerTokenAuthenticationToken("token"))) - .isInstanceOf(OAuth2AuthenticationException.class) - .extracting("error.errorCode") - .containsExactly("invalid_token"); - } - - @Test - public void authenticateWhenIntrospectionTokenReturnsMalformedIssuerResponseThenInvalidToken() { - RestOperations restOperations = mock(RestOperations.class); - OAuth2IntrospectionAuthenticationProvider provider = - new OAuth2IntrospectionAuthenticationProvider(INTROSPECTION_URL, restOperations); - when(restOperations.exchange(any(RequestEntity.class), eq(String.class))) - .thenReturn(MALFORMED_ISSUER); - - assertThatCode(() -> provider.authenticate(new BearerTokenAuthenticationToken("token"))) - .isInstanceOf(OAuth2AuthenticationException.class) - .extracting("error.errorCode") - .containsExactly("invalid_token"); - } - - @Test - public void constructorWhenIntrospectionUriIsNullThenIllegalArgumentException() { - assertThatCode(() -> new OAuth2IntrospectionAuthenticationProvider(null, CLIENT_ID, CLIENT_SECRET)) + public void constructorWhenIntrospectionClientIsNullThenIllegalArgumentException() { + assertThatCode(() -> new OAuth2IntrospectionAuthenticationProvider(null)) .isInstanceOf(IllegalArgumentException.class); } - - @Test - public void constructorWhenClientIdIsNullThenIllegalArgumentException() { - assertThatCode(() -> new OAuth2IntrospectionAuthenticationProvider(INTROSPECTION_URL, null, CLIENT_SECRET)) - .isInstanceOf(IllegalArgumentException.class); - } - - @Test - public void constructorWhenClientSecretIsNullThenIllegalArgumentException() { - assertThatCode(() -> new OAuth2IntrospectionAuthenticationProvider(INTROSPECTION_URL, CLIENT_ID, null)) - .isInstanceOf(IllegalArgumentException.class); - } - - @Test - public void constructorWhenRestOperationsIsNullThenIllegalArgumentException() { - assertThatCode(() -> new OAuth2IntrospectionAuthenticationProvider(INTROSPECTION_URL, null)) - .isInstanceOf(IllegalArgumentException.class); - } - - 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); - return Optional.ofNullable(authorization) - .filter(a -> isAuthorized(authorization, username, password)) - .map(a -> ok(response)) - .orElse(unauthorized()); - } - }; - } - - 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) { - return new MockResponse().setBody(response) - .setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE); - } - - 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/authentication/OAuth2IntrospectionAuthenticationTokenTests.java b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionAuthenticationTokenTests.java index 5374186f22..1f59b4743b 100644 --- a/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionAuthenticationTokenTests.java +++ b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionAuthenticationTokenTests.java @@ -31,9 +31,9 @@ import org.springframework.security.oauth2.core.OAuth2AccessToken; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; -import static org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames.CLIENT_ID; -import static org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames.SUBJECT; -import static org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames.USERNAME; +import static org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames.CLIENT_ID; +import static org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames.SUBJECT; +import static org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames.USERNAME; /** * Tests for {@link OAuth2IntrospectionAuthenticationToken} diff --git a/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionReactiveAuthenticationManagerTests.java b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionReactiveAuthenticationManagerTests.java index 24ccceab33..0b4cd69f5a 100644 --- a/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionReactiveAuthenticationManagerTests.java +++ b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionReactiveAuthenticationManagerTests.java @@ -16,155 +16,48 @@ package org.springframework.security.oauth2.server.resource.authentication; -import java.io.IOException; import java.net.URL; 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.JSONObject; -import okhttp3.mockwebserver.Dispatcher; -import okhttp3.mockwebserver.MockResponse; -import okhttp3.mockwebserver.MockWebServer; -import okhttp3.mockwebserver.RecordedRequest; import org.junit.Test; import reactor.core.publisher.Mono; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; import org.springframework.security.core.Authentication; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames; +import org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionException; +import org.springframework.security.oauth2.server.resource.introspection.ReactiveOAuth2TokenIntrospectionClient; import org.springframework.security.oauth2.server.resource.BearerTokenAuthenticationToken; -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.assertThatCode; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.spy; import static org.mockito.Mockito.when; -import static org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames.AUDIENCE; -import static org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames.EXPIRES_AT; -import static org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames.ISSUER; -import static org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames.NOT_BEFORE; -import static org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames.SCOPE; -import static org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames.SUBJECT; -import static org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames.USERNAME; +import static org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames.ACTIVE; +import static org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames.AUDIENCE; +import static org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames.EXPIRES_AT; +import static org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames.ISSUER; +import static org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames.NOT_BEFORE; +import static org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames.SCOPE; +import static org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames.SUBJECT; +import static org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames.USERNAME; +import static org.springframework.security.oauth2.server.resource.introspection.TestOAuth2TokenIntrospectionClientResponses.active; /** * Tests for {@link OAuth2IntrospectionReactiveAuthenticationManager} */ public class OAuth2IntrospectionReactiveAuthenticationManagerTests { - private static final String INTROSPECTION_URL = "https://server.example.com"; - private static final String CLIENT_ID = "client"; - private static final String CLIENT_SECRET = "secret"; - - 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" + - " }"; - - private static final String INACTIVE_RESPONSE = "{\n" + - " \"active\": false\n" + - " }"; - - 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" + - " }"; - - private static final String MALFORMED_ISSUER_RESPONSE = "{\n" + - " \"active\" : \"true\",\n" + - " \"iss\" : \"badissuer\"\n" + - " }"; - @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(); - OAuth2IntrospectionReactiveAuthenticationManager provider = - new OAuth2IntrospectionReactiveAuthenticationManager(introspectUri, CLIENT_ID, CLIENT_SECRET); - - Authentication result = - provider.authenticate(new BearerTokenAuthenticationToken("token")).block(); - - assertThat(result.getPrincipal()).isInstanceOf(Map.class); - - Map attributes = (Map) result.getPrincipal(); - assertThat(attributes) - .isNotNull() - .containsEntry(OAuth2IntrospectionClaimNames.ACTIVE, true) - .containsEntry(AUDIENCE, Arrays.asList("https://protected.example.net/resource")) - .containsEntry(OAuth2IntrospectionClaimNames.CLIENT_ID, "l238j323ds-23ij4") - .containsEntry(EXPIRES_AT, Instant.ofEpochSecond(1419356238)) - .containsEntry(ISSUER, new URL("https://server.example.com/")) - .containsEntry(SCOPE, Arrays.asList("read", "write", "dolphin")) - .containsEntry(SUBJECT, "Z5O3upPC88QrAjx00dis") - .containsEntry(USERNAME, "jdoe") - .containsEntry("extension_field", "twenty-seven"); - - assertThat(result.getAuthorities()).extracting("authority") - .containsExactly("SCOPE_read", "SCOPE_write", "SCOPE_dolphin"); - } - } - - @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(); - OAuth2IntrospectionReactiveAuthenticationManager provider = - new OAuth2IntrospectionReactiveAuthenticationManager(introspectUri, CLIENT_ID, "wrong"); - - assertThatCode(() -> provider.authenticate(new BearerTokenAuthenticationToken("token")).block()) - .isInstanceOf(OAuth2AuthenticationException.class); - } - } - - @Test - public void authenticateWhenInactiveTokenThenInvalidToken() { - WebClient webClient = mockResponse(INACTIVE_RESPONSE); + Map claims = active(); + claims.put("extension_field", "twenty-seven"); + ReactiveOAuth2TokenIntrospectionClient introspectionClient = mock(ReactiveOAuth2TokenIntrospectionClient.class); + when(introspectionClient.introspect(any())).thenReturn(Mono.just(claims)); OAuth2IntrospectionReactiveAuthenticationManager provider = - new OAuth2IntrospectionReactiveAuthenticationManager(INTROSPECTION_URL, webClient); - - assertThatCode(() -> provider.authenticate(new BearerTokenAuthenticationToken("token")).block()) - .isInstanceOf(OAuth2AuthenticationException.class) - .extracting("error.errorCode") - .containsExactly("invalid_token"); - } - - @Test - public void authenticateWhenActiveTokenThenParsesValuesInResponse() { - Map introspectedValues = new HashMap<>(); - introspectedValues.put(OAuth2IntrospectionClaimNames.ACTIVE, true); - introspectedValues.put(AUDIENCE, Arrays.asList("aud")); - introspectedValues.put(NOT_BEFORE, 29348723984L); - - WebClient webClient = mockResponse(new JSONObject(introspectedValues).toJSONString()); - OAuth2IntrospectionReactiveAuthenticationManager provider = - new OAuth2IntrospectionReactiveAuthenticationManager(INTROSPECTION_URL, webClient); + new OAuth2IntrospectionReactiveAuthenticationManager(introspectionClient); Authentication result = provider.authenticate(new BearerTokenAuthenticationToken("token")).block(); @@ -174,10 +67,37 @@ public class OAuth2IntrospectionReactiveAuthenticationManagerTests { Map attributes = (Map) result.getPrincipal(); assertThat(attributes) .isNotNull() - .containsEntry(OAuth2IntrospectionClaimNames.ACTIVE, true) - .containsEntry(AUDIENCE, Arrays.asList("aud")) + .containsEntry(ACTIVE, true) + .containsEntry(AUDIENCE, Arrays.asList("https://protected.example.net/resource")) + .containsEntry(OAuth2IntrospectionClaimNames.CLIENT_ID, "l238j323ds-23ij4") + .containsEntry(EXPIRES_AT, Instant.ofEpochSecond(1419356238)) + .containsEntry(ISSUER, new URL("https://server.example.com/")) .containsEntry(NOT_BEFORE, Instant.ofEpochSecond(29348723984L)) - .doesNotContainKey(OAuth2IntrospectionClaimNames.CLIENT_ID) + .containsEntry(SCOPE, Arrays.asList("read", "write", "dolphin")) + .containsEntry(SUBJECT, "Z5O3upPC88QrAjx00dis") + .containsEntry(USERNAME, "jdoe") + .containsEntry("extension_field", "twenty-seven"); + + assertThat(result.getAuthorities()).extracting("authority") + .containsExactly("SCOPE_read", "SCOPE_write", "SCOPE_dolphin"); + } + + @Test + public void authenticateWhenMissingScopeAttributeThenNoAuthorities() { + Map claims = active(); + claims.remove(SCOPE); + ReactiveOAuth2TokenIntrospectionClient introspectionClient = mock(ReactiveOAuth2TokenIntrospectionClient.class); + when(introspectionClient.introspect(any())).thenReturn(Mono.just(claims)); + OAuth2IntrospectionReactiveAuthenticationManager provider = + new OAuth2IntrospectionReactiveAuthenticationManager(introspectionClient); + + Authentication result = + provider.authenticate(new BearerTokenAuthenticationToken("token")).block(); + assertThat(result.getPrincipal()).isInstanceOf(Map.class); + + Map attributes = (Map) result.getPrincipal(); + assertThat(attributes) + .isNotNull() .doesNotContainKey(SCOPE); assertThat(result.getAuthorities()).isEmpty(); @@ -185,126 +105,21 @@ public class OAuth2IntrospectionReactiveAuthenticationManagerTests { @Test public void authenticateWhenIntrospectionEndpointThrowsExceptionThenInvalidToken() { - WebClient webClient = mockResponse(new IllegalStateException("server was unresponsive")); + ReactiveOAuth2TokenIntrospectionClient introspectionClient = mock(ReactiveOAuth2TokenIntrospectionClient.class); + when(introspectionClient.introspect(any())) + .thenReturn(Mono.error(new OAuth2IntrospectionException("with \"invalid\" chars"))); OAuth2IntrospectionReactiveAuthenticationManager provider = - new OAuth2IntrospectionReactiveAuthenticationManager(INTROSPECTION_URL, webClient); + new OAuth2IntrospectionReactiveAuthenticationManager(introspectionClient); assertThatCode(() -> provider.authenticate(new BearerTokenAuthenticationToken("token")).block()) .isInstanceOf(OAuth2AuthenticationException.class) - .extracting("error.errorCode") - .containsExactly("invalid_token"); - } - - - @Test - public void authenticateWhenIntrospectionEndpointReturnsMalformedResponseThenInvalidToken() { - WebClient webClient = mockResponse("malformed"); - OAuth2IntrospectionReactiveAuthenticationManager provider = - new OAuth2IntrospectionReactiveAuthenticationManager(INTROSPECTION_URL, webClient); - - assertThatCode(() -> provider.authenticate(new BearerTokenAuthenticationToken("token")).block()) - .isInstanceOf(OAuth2AuthenticationException.class) - .extracting("error.errorCode") - .containsExactly("invalid_token"); + .extracting("error.description") + .containsExactly("An error occurred while attempting to introspect the token: Invalid token"); } @Test - public void authenticateWhenIntrospectionTokenReturnsInvalidResponseThenInvalidToken() { - WebClient webClient = mockResponse(INVALID_RESPONSE); - OAuth2IntrospectionReactiveAuthenticationManager provider = - new OAuth2IntrospectionReactiveAuthenticationManager(INTROSPECTION_URL, webClient); - - assertThatCode(() -> provider.authenticate(new BearerTokenAuthenticationToken("token")).block()) - .isInstanceOf(OAuth2AuthenticationException.class) - .extracting("error.errorCode") - .containsExactly("invalid_token"); - } - - @Test - public void authenticateWhenIntrospectionTokenReturnsMalformedIssuerResponseThenInvalidToken() { - WebClient webClient = mockResponse(MALFORMED_ISSUER_RESPONSE); - OAuth2IntrospectionReactiveAuthenticationManager provider = - new OAuth2IntrospectionReactiveAuthenticationManager(INTROSPECTION_URL, webClient); - - assertThatCode(() -> provider.authenticate(new BearerTokenAuthenticationToken("token")).block()) - .isInstanceOf(OAuth2AuthenticationException.class) - .extracting("error.errorCode") - .containsExactly("invalid_token"); - } - - @Test - public void constructorWhenIntrospectionUriIsEmptyThenIllegalArgumentException() { - assertThatCode(() -> new OAuth2IntrospectionReactiveAuthenticationManager("", CLIENT_ID, CLIENT_SECRET)) + public void constructorWhenIntrospectionClientIsNullThenIllegalArgumentException() { + assertThatCode(() -> new OAuth2IntrospectionReactiveAuthenticationManager(null)) .isInstanceOf(IllegalArgumentException.class); } - - @Test - public void constructorWhenClientIdIsEmptyThenIllegalArgumentException() { - assertThatCode(() -> new OAuth2IntrospectionReactiveAuthenticationManager(INTROSPECTION_URL, "", CLIENT_SECRET)) - .isInstanceOf(IllegalArgumentException.class); - } - - @Test - public void constructorWhenClientSecretIsNullThenIllegalArgumentException() { - assertThatCode(() -> new OAuth2IntrospectionReactiveAuthenticationManager(INTROSPECTION_URL, CLIENT_ID, null)) - .isInstanceOf(IllegalArgumentException.class); - } - - @Test - public void constructorWhenRestOperationsIsNullThenIllegalArgumentException() { - assertThatCode(() -> new OAuth2IntrospectionReactiveAuthenticationManager(INTROSPECTION_URL, null)) - .isInstanceOf(IllegalArgumentException.class); - } - - private WebClient mockResponse(String response) { - WebClient real = WebClient.builder().build(); - WebClient.RequestBodyUriSpec spec = spy(real.post()); - WebClient webClient = spy(WebClient.class); - when(webClient.post()).thenReturn(spec); - ClientResponse clientResponse = mock(ClientResponse.class); - when(clientResponse.rawStatusCode()).thenReturn(200); - when(clientResponse.statusCode()).thenReturn(HttpStatus.OK); - when(clientResponse.bodyToMono(String.class)).thenReturn(Mono.just(response)); - ClientResponse.Headers headers = mock(ClientResponse.Headers.class); - when(headers.contentType()).thenReturn(Optional.of(MediaType.APPLICATION_JSON_UTF8)); - when(clientResponse.headers()).thenReturn(headers); - when(spec.exchange()).thenReturn(Mono.just(clientResponse)); - return webClient; - } - - private WebClient mockResponse(Throwable t) { - WebClient real = WebClient.builder().build(); - WebClient.RequestBodyUriSpec spec = spy(real.post()); - WebClient webClient = spy(WebClient.class); - when(webClient.post()).thenReturn(spec); - when(spec.exchange()).thenThrow(t); - 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); - return Optional.ofNullable(authorization) - .filter(a -> isAuthorized(authorization, username, password)) - .map(a -> ok(response)) - .orElse(unauthorized()); - } - }; - } - - 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) { - return new MockResponse().setBody(response) - .setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE); - } - - 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/NimbusOAuth2TokenIntrospectionClientTests.java b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/introspection/NimbusOAuth2TokenIntrospectionClientTests.java new file mode 100644 index 0000000000..48b130c97a --- /dev/null +++ b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/introspection/NimbusOAuth2TokenIntrospectionClientTests.java @@ -0,0 +1,289 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.oauth2.server.resource.introspection; + +import java.io.IOException; +import java.net.URL; +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.JSONObject; +import okhttp3.mockwebserver.Dispatcher; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.Test; + +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.server.resource.introspection.NimbusOAuth2TokenIntrospectionClient; +import org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames; +import org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionException; +import org.springframework.security.oauth2.server.resource.introspection.OAuth2TokenIntrospectionClient; +import org.springframework.web.client.RestOperations; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames.AUDIENCE; +import static org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames.EXPIRES_AT; +import static org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames.ISSUER; +import static org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames.NOT_BEFORE; +import static org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames.SCOPE; +import static org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames.SUBJECT; +import static org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames.USERNAME; + +/** + * Tests for {@link NimbusOAuth2TokenIntrospectionClient} + */ +public class NimbusOAuth2TokenIntrospectionClientTests { + + private static final String INTROSPECTION_URL = "https://server.example.com"; + private static final String CLIENT_ID = "client"; + private static final String CLIENT_SECRET = "secret"; + + 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" + + " }"; + + private static final String INACTIVE_RESPONSE = "{\n" + + " \"active\": false\n" + + " }"; + + 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" + + " }"; + + private static final String MALFORMED_ISSUER_RESPONSE = "{\n" + + " \"active\" : \"true\",\n" + + " \"iss\" : \"badissuer\"\n" + + " }"; + + 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); + + @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(); + OAuth2TokenIntrospectionClient introspectionClient = + new NimbusOAuth2TokenIntrospectionClient(introspectUri, CLIENT_ID, CLIENT_SECRET); + + Map attributes = introspectionClient.introspect("token"); + assertThat(attributes) + .isNotNull() + .containsEntry(OAuth2IntrospectionClaimNames.ACTIVE, true) + .containsEntry(AUDIENCE, Arrays.asList("https://protected.example.net/resource")) + .containsEntry(OAuth2IntrospectionClaimNames.CLIENT_ID, "l238j323ds-23ij4") + .containsEntry(EXPIRES_AT, Instant.ofEpochSecond(1419356238)) + .containsEntry(ISSUER, new URL("https://server.example.com/")) + .containsEntry(SCOPE, Arrays.asList("read", "write", "dolphin")) + .containsEntry(SUBJECT, "Z5O3upPC88QrAjx00dis") + .containsEntry(USERNAME, "jdoe") + .containsEntry("extension_field", "twenty-seven"); + } + } + + @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(); + OAuth2TokenIntrospectionClient introspectionClient = + new NimbusOAuth2TokenIntrospectionClient(introspectUri, CLIENT_ID, "wrong"); + + assertThatCode(() -> introspectionClient.introspect("token")) + .isInstanceOf(OAuth2IntrospectionException.class); + } + } + + @Test + public void introspectWhenInactiveTokenThenInvalidToken() { + RestOperations restOperations = mock(RestOperations.class); + OAuth2TokenIntrospectionClient introspectionClient = new NimbusOAuth2TokenIntrospectionClient(INTROSPECTION_URL, restOperations); + when(restOperations.exchange(any(RequestEntity.class), eq(String.class))) + .thenReturn(INACTIVE); + + assertThatCode(() -> introspectionClient.introspect("token")) + .isInstanceOf(OAuth2IntrospectionException.class) + .extracting("message") + .containsExactly("Provided token [token] isn't active"); + } + + @Test + public void introspectWhenActiveTokenThenParsesValuesInResponse() { + Map introspectedValues = new HashMap<>(); + introspectedValues.put(OAuth2IntrospectionClaimNames.ACTIVE, true); + introspectedValues.put(AUDIENCE, Arrays.asList("aud")); + introspectedValues.put(NOT_BEFORE, 29348723984L); + + RestOperations restOperations = mock(RestOperations.class); + OAuth2TokenIntrospectionClient introspectionClient = + new NimbusOAuth2TokenIntrospectionClient(INTROSPECTION_URL, restOperations); + when(restOperations.exchange(any(RequestEntity.class), eq(String.class))) + .thenReturn(response(new JSONObject(introspectedValues).toJSONString())); + + Map attributes = introspectionClient.introspect("token"); + assertThat(attributes) + .isNotNull() + .containsEntry(OAuth2IntrospectionClaimNames.ACTIVE, true) + .containsEntry(AUDIENCE, Arrays.asList("aud")) + .containsEntry(NOT_BEFORE, Instant.ofEpochSecond(29348723984L)) + .doesNotContainKey(OAuth2IntrospectionClaimNames.CLIENT_ID) + .doesNotContainKey(SCOPE); + } + + @Test + public void introspectWhenIntrospectionEndpointThrowsExceptionThenInvalidToken() { + RestOperations restOperations = mock(RestOperations.class); + OAuth2TokenIntrospectionClient introspectionClient = + new NimbusOAuth2TokenIntrospectionClient(INTROSPECTION_URL, restOperations); + when(restOperations.exchange(any(RequestEntity.class), eq(String.class))) + .thenThrow(new IllegalStateException("server was unresponsive")); + + assertThatCode(() -> introspectionClient.introspect("token")) + .isInstanceOf(OAuth2IntrospectionException.class) + .extracting("message") + .containsExactly("server was unresponsive"); + } + + + @Test + public void introspectWhenIntrospectionEndpointReturnsMalformedResponseThenInvalidToken() { + RestOperations restOperations = mock(RestOperations.class); + OAuth2TokenIntrospectionClient introspectionClient = + new NimbusOAuth2TokenIntrospectionClient(INTROSPECTION_URL, restOperations); + when(restOperations.exchange(any(RequestEntity.class), eq(String.class))) + .thenReturn(response("malformed")); + + assertThatCode(() -> introspectionClient.introspect("token")) + .isInstanceOf(OAuth2IntrospectionException.class); + } + + @Test + public void introspectWhenIntrospectionTokenReturnsInvalidResponseThenInvalidToken() { + RestOperations restOperations = mock(RestOperations.class); + OAuth2TokenIntrospectionClient introspectionClient = + new NimbusOAuth2TokenIntrospectionClient(INTROSPECTION_URL, restOperations); + when(restOperations.exchange(any(RequestEntity.class), eq(String.class))) + .thenReturn(INVALID); + + assertThatCode(() -> introspectionClient.introspect("token")) + .isInstanceOf(OAuth2IntrospectionException.class); + } + + @Test + public void introspectWhenIntrospectionTokenReturnsMalformedIssuerResponseThenInvalidToken() { + RestOperations restOperations = mock(RestOperations.class); + OAuth2TokenIntrospectionClient introspectionClient = + new NimbusOAuth2TokenIntrospectionClient(INTROSPECTION_URL, restOperations); + when(restOperations.exchange(any(RequestEntity.class), eq(String.class))) + .thenReturn(MALFORMED_ISSUER); + + assertThatCode(() -> introspectionClient.introspect("token")) + .isInstanceOf(OAuth2IntrospectionException.class); + } + + @Test + public void constructorWhenIntrospectionUriIsNullThenIllegalArgumentException() { + assertThatCode(() -> new NimbusOAuth2TokenIntrospectionClient(null, CLIENT_ID, CLIENT_SECRET)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void constructorWhenClientIdIsNullThenIllegalArgumentException() { + assertThatCode(() -> new NimbusOAuth2TokenIntrospectionClient(INTROSPECTION_URL, null, CLIENT_SECRET)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void constructorWhenClientSecretIsNullThenIllegalArgumentException() { + assertThatCode(() -> new NimbusOAuth2TokenIntrospectionClient(INTROSPECTION_URL, CLIENT_ID, null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void constructorWhenRestOperationsIsNullThenIllegalArgumentException() { + assertThatCode(() -> new NimbusOAuth2TokenIntrospectionClient(INTROSPECTION_URL, null)) + .isInstanceOf(IllegalArgumentException.class); + } + + 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); + return Optional.ofNullable(authorization) + .filter(a -> isAuthorized(authorization, username, password)) + .map(a -> ok(response)) + .orElse(unauthorized()); + } + }; + } + + 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) { + return new MockResponse().setBody(response) + .setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE); + } + + 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/NimbusReactiveOAuth2TokenIntrospectionClientTests.java b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/introspection/NimbusReactiveOAuth2TokenIntrospectionClientTests.java new file mode 100644 index 0000000000..d760a925f8 --- /dev/null +++ b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/introspection/NimbusReactiveOAuth2TokenIntrospectionClientTests.java @@ -0,0 +1,288 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.oauth2.server.resource.introspection; + +import java.io.IOException; +import java.net.URL; +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.JSONObject; +import okhttp3.mockwebserver.Dispatcher; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.Test; +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.server.resource.introspection.NimbusReactiveOAuth2TokenIntrospectionClient; +import org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames; +import org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionException; +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.assertThatCode; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; +import static org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames.AUDIENCE; +import static org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames.EXPIRES_AT; +import static org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames.ISSUER; +import static org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames.NOT_BEFORE; +import static org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames.SCOPE; +import static org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames.SUBJECT; +import static org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames.USERNAME; + +/** + * Tests for {@link NimbusReactiveOAuth2TokenIntrospectionClient} + */ +public class NimbusReactiveOAuth2TokenIntrospectionClientTests { + private static final String INTROSPECTION_URL = "https://server.example.com"; + private static final String CLIENT_ID = "client"; + private static final String CLIENT_SECRET = "secret"; + + 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" + + " }"; + + private static final String INACTIVE_RESPONSE = "{\n" + + " \"active\": false\n" + + " }"; + + 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" + + " }"; + + private static final String MALFORMED_ISSUER_RESPONSE = "{\n" + + " \"active\" : \"true\",\n" + + " \"iss\" : \"badissuer\"\n" + + " }"; + + @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(); + NimbusReactiveOAuth2TokenIntrospectionClient introspectionClient = + new NimbusReactiveOAuth2TokenIntrospectionClient(introspectUri, CLIENT_ID, CLIENT_SECRET); + + Map attributes = introspectionClient.introspect("token").block(); + assertThat(attributes) + .isNotNull() + .containsEntry(OAuth2IntrospectionClaimNames.ACTIVE, true) + .containsEntry(AUDIENCE, Arrays.asList("https://protected.example.net/resource")) + .containsEntry(OAuth2IntrospectionClaimNames.CLIENT_ID, "l238j323ds-23ij4") + .containsEntry(EXPIRES_AT, Instant.ofEpochSecond(1419356238)) + .containsEntry(ISSUER, new URL("https://server.example.com/")) + .containsEntry(SCOPE, Arrays.asList("read", "write", "dolphin")) + .containsEntry(SUBJECT, "Z5O3upPC88QrAjx00dis") + .containsEntry(USERNAME, "jdoe") + .containsEntry("extension_field", "twenty-seven"); + } + } + + @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(); + NimbusReactiveOAuth2TokenIntrospectionClient introspectionClient = + new NimbusReactiveOAuth2TokenIntrospectionClient(introspectUri, CLIENT_ID, "wrong"); + + assertThatCode(() -> introspectionClient.introspect("token").block()) + .isInstanceOf(OAuth2IntrospectionException.class); + } + } + + @Test + public void authenticateWhenInactiveTokenThenInvalidToken() { + WebClient webClient = mockResponse(INACTIVE_RESPONSE); + NimbusReactiveOAuth2TokenIntrospectionClient introspectionClient = + new NimbusReactiveOAuth2TokenIntrospectionClient(INTROSPECTION_URL, webClient); + + assertThatCode(() -> introspectionClient.introspect("token").block()) + .isInstanceOf(OAuth2IntrospectionException.class) + .extracting("message") + .containsExactly("Provided token [token] isn't active"); + } + + @Test + public void authenticateWhenActiveTokenThenParsesValuesInResponse() { + Map introspectedValues = new HashMap<>(); + introspectedValues.put(OAuth2IntrospectionClaimNames.ACTIVE, true); + introspectedValues.put(AUDIENCE, Arrays.asList("aud")); + introspectedValues.put(NOT_BEFORE, 29348723984L); + + WebClient webClient = mockResponse(new JSONObject(introspectedValues).toJSONString()); + NimbusReactiveOAuth2TokenIntrospectionClient introspectionClient = + new NimbusReactiveOAuth2TokenIntrospectionClient(INTROSPECTION_URL, webClient); + + Map attributes = introspectionClient.introspect("token").block(); + assertThat(attributes) + .isNotNull() + .containsEntry(OAuth2IntrospectionClaimNames.ACTIVE, true) + .containsEntry(AUDIENCE, Arrays.asList("aud")) + .containsEntry(NOT_BEFORE, Instant.ofEpochSecond(29348723984L)) + .doesNotContainKey(OAuth2IntrospectionClaimNames.CLIENT_ID) + .doesNotContainKey(SCOPE); + } + + @Test + public void authenticateWhenIntrospectionEndpointThrowsExceptionThenInvalidToken() { + WebClient webClient = mockResponse(new IllegalStateException("server was unresponsive")); + NimbusReactiveOAuth2TokenIntrospectionClient introspectionClient = + new NimbusReactiveOAuth2TokenIntrospectionClient(INTROSPECTION_URL, webClient); + + assertThatCode(() -> introspectionClient.introspect("token").block()) + .isInstanceOf(OAuth2IntrospectionException.class) + .extracting("message") + .containsExactly("server was unresponsive"); + } + + @Test + public void authenticateWhenIntrospectionEndpointReturnsMalformedResponseThenInvalidToken() { + WebClient webClient = mockResponse("malformed"); + NimbusReactiveOAuth2TokenIntrospectionClient introspectionClient = + new NimbusReactiveOAuth2TokenIntrospectionClient(INTROSPECTION_URL, webClient); + + assertThatCode(() -> introspectionClient.introspect("token").block()) + .isInstanceOf(OAuth2IntrospectionException.class); + } + + @Test + public void authenticateWhenIntrospectionTokenReturnsInvalidResponseThenInvalidToken() { + WebClient webClient = mockResponse(INVALID_RESPONSE); + NimbusReactiveOAuth2TokenIntrospectionClient introspectionClient = + new NimbusReactiveOAuth2TokenIntrospectionClient(INTROSPECTION_URL, webClient); + + assertThatCode(() -> introspectionClient.introspect("token").block()) + .isInstanceOf(OAuth2IntrospectionException.class); + } + + @Test + public void authenticateWhenIntrospectionTokenReturnsMalformedIssuerResponseThenInvalidToken() { + WebClient webClient = mockResponse(MALFORMED_ISSUER_RESPONSE); + NimbusReactiveOAuth2TokenIntrospectionClient introspectionClient = + new NimbusReactiveOAuth2TokenIntrospectionClient(INTROSPECTION_URL, webClient); + + assertThatCode(() -> introspectionClient.introspect("token").block()) + .isInstanceOf(OAuth2IntrospectionException.class); + } + + @Test + public void constructorWhenIntrospectionUriIsEmptyThenIllegalArgumentException() { + assertThatCode(() -> new NimbusReactiveOAuth2TokenIntrospectionClient("", CLIENT_ID, CLIENT_SECRET)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void constructorWhenClientIdIsEmptyThenIllegalArgumentException() { + assertThatCode(() -> new NimbusReactiveOAuth2TokenIntrospectionClient(INTROSPECTION_URL, "", CLIENT_SECRET)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void constructorWhenClientSecretIsNullThenIllegalArgumentException() { + assertThatCode(() -> new NimbusReactiveOAuth2TokenIntrospectionClient(INTROSPECTION_URL, CLIENT_ID, null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void constructorWhenRestOperationsIsNullThenIllegalArgumentException() { + assertThatCode(() -> new NimbusReactiveOAuth2TokenIntrospectionClient(INTROSPECTION_URL, null)) + .isInstanceOf(IllegalArgumentException.class); + } + + private WebClient mockResponse(String response) { + WebClient real = WebClient.builder().build(); + WebClient.RequestBodyUriSpec spec = spy(real.post()); + WebClient webClient = spy(WebClient.class); + when(webClient.post()).thenReturn(spec); + ClientResponse clientResponse = mock(ClientResponse.class); + when(clientResponse.rawStatusCode()).thenReturn(200); + when(clientResponse.statusCode()).thenReturn(HttpStatus.OK); + when(clientResponse.bodyToMono(String.class)).thenReturn(Mono.just(response)); + ClientResponse.Headers headers = mock(ClientResponse.Headers.class); + when(headers.contentType()).thenReturn(Optional.of(MediaType.APPLICATION_JSON_UTF8)); + when(clientResponse.headers()).thenReturn(headers); + when(spec.exchange()).thenReturn(Mono.just(clientResponse)); + return webClient; + } + + private WebClient mockResponse(Throwable t) { + WebClient real = WebClient.builder().build(); + WebClient.RequestBodyUriSpec spec = spy(real.post()); + WebClient webClient = spy(WebClient.class); + when(webClient.post()).thenReturn(spec); + when(spec.exchange()).thenThrow(t); + 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); + return Optional.ofNullable(authorization) + .filter(a -> isAuthorized(authorization, username, password)) + .map(a -> ok(response)) + .orElse(unauthorized()); + } + }; + } + + 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) { + return new MockResponse().setBody(response) + .setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE); + } + + 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/TestOAuth2TokenIntrospectionClientResponses.java b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/introspection/TestOAuth2TokenIntrospectionClientResponses.java new file mode 100644 index 0000000000..27345522a2 --- /dev/null +++ b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/introspection/TestOAuth2TokenIntrospectionClientResponses.java @@ -0,0 +1,59 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.oauth2.server.resource.introspection; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.URL; +import java.time.Instant; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; + +import static org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames.ACTIVE; +import static org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames.AUDIENCE; +import static org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames.CLIENT_ID; +import static org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames.EXPIRES_AT; +import static org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames.ISSUER; +import static org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames.NOT_BEFORE; +import static org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames.SCOPE; +import static org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames.SUBJECT; +import static org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames.USERNAME; + +public class TestOAuth2TokenIntrospectionClientResponses { + public static Map active() { + Map attributes = new HashMap<>(); + attributes.put(ACTIVE, true); + attributes.put(AUDIENCE, Arrays.asList("https://protected.example.net/resource")); + attributes.put(CLIENT_ID, "l238j323ds-23ij4"); + attributes.put(EXPIRES_AT, Instant.ofEpochSecond(1419356238)); + attributes.put(NOT_BEFORE, Instant.ofEpochSecond(29348723984L)); + attributes.put(ISSUER, url("https://server.example.com/")); + attributes.put(SCOPE, Arrays.asList("read", "write", "dolphin")); + attributes.put(SUBJECT, "Z5O3upPC88QrAjx00dis"); + attributes.put(USERNAME, "jdoe"); + return attributes; + } + + private static URL url(String url) { + try { + return new URL(url); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +} diff --git a/samples/boot/oauth2resourceserver-multitenancy/src/main/java/sample/OAuth2ResourceServerSecurityConfiguration.java b/samples/boot/oauth2resourceserver-multitenancy/src/main/java/sample/OAuth2ResourceServerSecurityConfiguration.java index 7977d92fa9..43f8b5e0d0 100644 --- a/samples/boot/oauth2resourceserver-multitenancy/src/main/java/sample/OAuth2ResourceServerSecurityConfiguration.java +++ b/samples/boot/oauth2resourceserver-multitenancy/src/main/java/sample/OAuth2ResourceServerSecurityConfiguration.java @@ -27,6 +27,8 @@ import org.springframework.security.authentication.AuthenticationManagerResolver import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; +import org.springframework.security.oauth2.server.resource.introspection.NimbusOAuth2TokenIntrospectionClient; +import org.springframework.security.oauth2.server.resource.introspection.OAuth2TokenIntrospectionClient; import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationProvider; @@ -75,6 +77,8 @@ public class OAuth2ResourceServerSecurityConfiguration extends WebSecurityConfig } AuthenticationManager opaque() { - return new OAuth2IntrospectionAuthenticationProvider(this.introspectionUri, "client", "secret")::authenticate; + OAuth2TokenIntrospectionClient introspectionClient = + new NimbusOAuth2TokenIntrospectionClient(this.introspectionUri, "client", "secret"); + return new OAuth2IntrospectionAuthenticationProvider(introspectionClient)::authenticate; } }