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; } }