From ef9c3e4771d3bad723f0bb427aad9e5e0a36b762 Mon Sep 17 00:00:00 2001 From: Josh Cummings Date: Thu, 11 Oct 2018 15:20:02 -0600 Subject: [PATCH] Opaque Token Support Fixes: gh-5200 --- .../OAuth2ResourceServerConfigurer.java | 81 ++++- .../OAuth2ResourceServerConfigurerTests.java | 24 +- ...ing-security-oauth2-resource-server.gradle | 1 + ...bstractOAuth2TokenAuthenticationToken.java | 19 +- ...h2IntrospectionAuthenticationProvider.java | 283 ++++++++++++++++ ...Auth2IntrospectionAuthenticationToken.java | 91 +++++ .../OAuth2IntrospectionClaimNames.java | 86 +++++ ...rospectionAuthenticationProviderTests.java | 311 ++++++++++++++++++ ...IntrospectionAuthenticationTokenTests.java | 120 +++++++ 9 files changed, 996 insertions(+), 20 deletions(-) create mode 100644 oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionAuthenticationProvider.java create mode 100644 oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionAuthenticationToken.java create mode 100644 oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionClaimNames.java create mode 100644 oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionAuthenticationProviderTests.java create mode 100644 oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionAuthenticationTokenTests.java diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurer.java index eb13966cd5..ea468369f1 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 @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * 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. @@ -22,6 +22,7 @@ import org.springframework.context.ApplicationContext; import org.springframework.core.convert.converter.Converter; import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.authentication.AuthenticationManager; +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; @@ -31,6 +32,7 @@ import org.springframework.security.oauth2.core.OAuth2AuthenticationException; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionAuthenticationProvider; 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.web.BearerTokenAuthenticationEntryPoint; @@ -86,6 +88,10 @@ import static org.springframework.security.oauth2.jwt.JwtProcessors.withJwkSetUr * * * + *

+ * When using {@link #opaque()}, supply an introspection endpoint and its authentication configuration + *

+ * *

Security Filters

* * The following {@code Filter}s are populated when {@link #jwt()} is configured: @@ -123,7 +129,9 @@ public final class OAuth2ResourceServerConfigurer jwtAuthenticationConverter = - this.jwtConfigurer.getJwtAuthenticationConverter(); + if (this.jwtConfigurer == null && this.opaqueTokenConfigurer == null) { + throw new IllegalStateException("Jwt and Opaque Token are the only supported formats for bearer tokens " + + "in Spring Security and neither was found. Make sure to configure JWT " + + "via http.oauth2ResourceServer().jwt() or Opaque Tokens via " + + "http.oauth2ResourceServer().opaque()."); + } - JwtAuthenticationProvider provider = - new JwtAuthenticationProvider(decoder); - provider.setJwtAuthenticationConverter(jwtAuthenticationConverter); - provider = postProcess(provider); + if (this.jwtConfigurer != null) { + JwtDecoder decoder = this.jwtConfigurer.getJwtDecoder(); + Converter jwtAuthenticationConverter = + this.jwtConfigurer.getJwtAuthenticationConverter(); - http.authenticationProvider(provider); + JwtAuthenticationProvider provider = + new JwtAuthenticationProvider(decoder); + provider.setJwtAuthenticationConverter(jwtAuthenticationConverter); + provider = postProcess(provider); + + http.authenticationProvider(provider); + } + + if (this.opaqueTokenConfigurer != null) { + http.authenticationProvider(this.opaqueTokenConfigurer.getProvider()); + } } public class JwtConfigurer { @@ -248,6 +274,31 @@ public final class OAuth2ResourceServerConfigurer exceptionHandling = http .getConfigurer(ExceptionHandlingConfigurer.class); 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 2abf49d559..2eb6c0aad3 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 @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * 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. @@ -1109,7 +1109,7 @@ public class OAuth2ResourceServerConfigurerTests { assertThatCode(() -> this.spring.register(JwtlessConfig.class).autowire()) .isInstanceOf(BeanCreationException.class) - .hasMessageContaining("no Jwt configuration was found"); + .hasMessageContaining("neither was found"); } @Test @@ -1120,6 +1120,13 @@ public class OAuth2ResourceServerConfigurerTests { .hasMessageContaining("No qualifying bean of type"); } + @Test + public void configureWhenUsingBothJwtAndOpaqueThenWiringException() { + assertThatCode(() -> this.spring.register(OpaqueAndJwtConfig.class).autowire()) + .isInstanceOf(BeanCreationException.class) + .hasMessageContaining("Spring Security only supports JWTs or Opaque Tokens"); + } + // -- support @EnableWebSecurity @@ -1623,6 +1630,19 @@ public class OAuth2ResourceServerConfigurerTests { } } + @EnableWebSecurity + static class OpaqueAndJwtConfig extends WebSecurityConfigurerAdapter { + @Override + protected void configure(HttpSecurity http) throws Exception { + // @formatter:off + http + .oauth2ResourceServer() + .jwt() + .and() + .opaqueToken(); + } + } + @Configuration static class JwtDecoderConfig { @Bean diff --git a/oauth2/oauth2-resource-server/spring-security-oauth2-resource-server.gradle b/oauth2/oauth2-resource-server/spring-security-oauth2-resource-server.gradle index 7fac327e25..10159c9a8d 100644 --- a/oauth2/oauth2-resource-server/spring-security-oauth2-resource-server.gradle +++ b/oauth2/oauth2-resource-server/spring-security-oauth2-resource-server.gradle @@ -7,6 +7,7 @@ dependencies { compile springCoreDependency optional project(':spring-security-oauth2-jose') + optional 'com.nimbusds:oauth2-oidc-sdk' optional 'io.projectreactor:reactor-core' optional 'org.springframework:spring-webflux' diff --git a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/AbstractOAuth2TokenAuthenticationToken.java b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/AbstractOAuth2TokenAuthenticationToken.java index 896ac031d8..6a4a231d85 100644 --- a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/AbstractOAuth2TokenAuthenticationToken.java +++ b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/AbstractOAuth2TokenAuthenticationToken.java @@ -45,6 +45,8 @@ import org.springframework.util.Assert; public abstract class AbstractOAuth2TokenAuthenticationToken extends AbstractAuthenticationToken { private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID; + private Object principal; + private Object credentials; private T token; /** @@ -64,9 +66,20 @@ public abstract class AbstractOAuth2TokenAuthenticationToken authorities) { - super(authorities); + this(token, token, token, authorities); + } + protected AbstractOAuth2TokenAuthenticationToken( + T token, + Object principal, + Object credentials, + Collection authorities) { + + super(authorities); Assert.notNull(token, "token cannot be null"); + Assert.notNull(principal, "principal cannot be null"); + this.principal = principal; + this.credentials = credentials; this.token = token; } @@ -75,7 +88,7 @@ public abstract class AbstractOAuth2TokenAuthenticationTokenBearer Tokens, + * using an + * OAuth 2.0 Introspection Endpoint + * to check the token's validity and reveal its attributes. + *

+ * This {@link AuthenticationProvider} is responsible for introspecting and verifying an opaque access token, + * returning its attributes set as part of the {@see Authentication} statement. + *

+ * Scopes are translated into {@link GrantedAuthority}s according to the following algorithm: + *

    + *
  1. + * If there is a "scope" attribute, then convert to a {@link Collection} of {@link String}s. + *
  2. + * Take the resulting {@link Collection} and prepend the "SCOPE_" keyword to each element, adding as {@link GrantedAuthority}s. + *
+ * + * @author Josh Cummings + * @since 5.2 + * @see AuthenticationProvider + */ +public final class OAuth2IntrospectionAuthenticationProvider implements AuthenticationProvider { + 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 secret for the authorized client + */ + 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; + } + + /** + * Introspect and validate the opaque + * Bearer Token. + * + * @param authentication the authentication request object. + * + * @return A successful authentication + * @throws AuthenticationException if authentication failed for some reason + */ + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + 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); + result.setDetails(bearer.getDetails()); + return result; + } + + /** + * {@inheritDoc} + */ + @Override + public boolean supports(Class authentication) { + 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 Collection extractAuthorities(Map claims) { + Collection scopes = (Collection) claims.get(SCOPE); + return Optional.ofNullable(scopes).orElse(Collections.emptyList()) + .stream() + .map(authority -> new SimpleGrantedAuthority("SCOPE_" + authority)) + .collect(Collectors.toList()); + } + + private URL issuer(String uri) { + try { + return new URL(uri); + } catch (Exception ex) { + throw new OAuth2AuthenticationException( + invalidToken("Invalid " + ISSUER + " value: " + uri), ex); + } + } + + 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 new file mode 100644 index 0000000000..eb2d5d44bc --- /dev/null +++ b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionAuthenticationToken.java @@ -0,0 +1,91 @@ +/* + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.oauth2.server.resource.authentication; + +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.springframework.security.core.GrantedAuthority; +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; + +/** + * An {@link org.springframework.security.core.Authentication} token that represents a successful authentication as + * obtained through an opaque token + * introspection + * process. + * + * @author Josh Cummings + * @since 5.2 + */ +public class OAuth2IntrospectionAuthenticationToken + extends AbstractOAuth2TokenAuthenticationToken { + + private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID; + + private Map attributes; + private String name; + + /** + * Constructs a {@link OAuth2IntrospectionAuthenticationToken} with the provided arguments + * + * @param token The verified token + * @param authorities The authorities associated with the given token + */ + public OAuth2IntrospectionAuthenticationToken(OAuth2AccessToken token, + Map attributes, Collection authorities) { + + this(token, attributes, authorities, null); + } + + /** + * Constructs a {@link OAuth2IntrospectionAuthenticationToken} with the provided arguments + * + * @param token The verified token + * @param authorities The authorities associated with the given token + * @param name The name associated with this token + */ + public OAuth2IntrospectionAuthenticationToken(OAuth2AccessToken token, + Map attributes, Collection authorities, String name) { + + super(token, attributes, token, authorities); + Assert.notEmpty(attributes, "attributes cannot be empty"); + this.attributes = Collections.unmodifiableMap(new LinkedHashMap<>(attributes)); + this.name = name == null ? (String) attributes.get(SUBJECT) : name; + setAuthenticated(true); + } + + /** + * {@inheritDoc} + */ + @Override + public Map getTokenAttributes() { + return this.attributes; + } + + /** + * {@inheritDoc} + */ + @Override + public String getName() { + return this.name; + } +} 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/authentication/OAuth2IntrospectionClaimNames.java new file mode 100644 index 0000000000..d6eb91c0b6 --- /dev/null +++ b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionClaimNames.java @@ -0,0 +1,86 @@ +/* + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.oauth2.server.resource.authentication; + +/** + * The names of the "Introspection Claims" defined by an + * Introspection Response. + * + * @author Josh Cummings + * @since 5.2 + */ +interface OAuth2IntrospectionClaimNames { + + /** + * {@code active} - Indicator whether or not the token is currently active + */ + String ACTIVE = "active"; + + /** + * {@code scope} - The scopes for the token + */ + String SCOPE = "scope"; + + /** + * {@code client_id} - The Client identifier for the token + */ + String CLIENT_ID = "client_id"; + + /** + * {@code username} - A human-readable identifier for the resource owner that authorized the token + */ + String USERNAME = "username"; + + /** + * {@code token_type} - The type of the token, for example {@code bearer}. + */ + String TOKEN_TYPE = "token_type"; + + /** + * {@code exp} - A timestamp indicating when the token expires + */ + String EXPIRES_AT = "exp"; + + /** + * {@code iat} - A timestamp indicating when the token was issued + */ + String ISSUED_AT = "iat"; + + /** + * {@code nbf} - A timestamp indicating when the token is not to be used before + */ + String NOT_BEFORE = "nbf"; + + /** + * {@code sub} - Usually a machine-readable identifier of the resource owner who authorized the token + */ + String SUBJECT = "sub"; + + /** + * {@code aud} - The intended audience for the token + */ + String AUDIENCE = "aud"; + + /** + * {@code iss} - The issuer of the token + */ + String ISSUER = "iss"; + + /** + * {@code jti} - The identifier for the token + */ + String JTI = "jti"; +} 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 new file mode 100644 index 0000000000..98cbfcb1b4 --- /dev/null +++ b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionAuthenticationProviderTests.java @@ -0,0 +1,311 @@ +/* + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.oauth2.server.resource.authentication; + +import java.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.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; + +/** + * Tests for {@link OAuth2IntrospectionAuthenticationProvider} + * + * @author Josh Cummings + * @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); + 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())); + + 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("aud")) + .containsEntry(NOT_BEFORE, Instant.ofEpochSecond(29348723984L)) + .doesNotContainKey(OAuth2IntrospectionClaimNames.CLIENT_ID) + .doesNotContainKey(SCOPE); + + assertThat(result.getAuthorities()).isEmpty(); + } + + @Test + public void authenticateWhenIntrospectionEndpointThrowsExceptionThenInvalidToken() { + RestOperations restOperations = mock(RestOperations.class); + OAuth2IntrospectionAuthenticationProvider provider = + new OAuth2IntrospectionAuthenticationProvider(INTROSPECTION_URL, restOperations); + when(restOperations.exchange(any(RequestEntity.class), eq(String.class))) + .thenThrow(new IllegalStateException("server was unresponsive")); + + 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"); + } + + @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)) + .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 new file mode 100644 index 0000000000..0f56512006 --- /dev/null +++ b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionAuthenticationTokenTests.java @@ -0,0 +1,120 @@ +/* + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.oauth2.server.resource.authentication; + +import java.time.Instant; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.Before; +import org.junit.Test; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.AuthorityUtils; +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; + +/** + * Tests for {@link OAuth2IntrospectionAuthenticationToken} + * + * @author Josh Cummings + */ +public class OAuth2IntrospectionAuthenticationTokenTests { + private final OAuth2AccessToken token = + new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, + "token", Instant.now(), Instant.now().plusSeconds(3600)); + private final Map attributes = new HashMap<>(); + private final String name = "sub"; + + @Before + public void setUp() { + this.attributes.put(SUBJECT, this.name); + this.attributes.put(CLIENT_ID, "client_id"); + this.attributes.put(USERNAME, "username"); + } + + @Test + public void getNameWhenConfiguredInConstructorThenReturnsName() { + OAuth2IntrospectionAuthenticationToken authenticated = + new OAuth2IntrospectionAuthenticationToken(this.token, this.attributes, + AuthorityUtils.createAuthorityList("USER"), this.name); + assertThat(authenticated.getName()).isEqualTo(this.name); + } + + @Test + public void getNameWhenHasNoSubjectThenReturnsNull() { + OAuth2IntrospectionAuthenticationToken authenticated = + new OAuth2IntrospectionAuthenticationToken(this.token, Collections.singletonMap("claim", "value"), + Collections.emptyList()); + assertThat(authenticated.getName()).isNull(); + } + + @Test + public void getNameWhenTokenHasUsernameThenReturnsUsernameAttribute() { + OAuth2IntrospectionAuthenticationToken authenticated = + new OAuth2IntrospectionAuthenticationToken(this.token, this.attributes, Collections.emptyList()); + assertThat(authenticated.getName()).isEqualTo(this.attributes.get(SUBJECT)); + } + + @Test + public void constructorWhenTokenIsNullThenThrowsException() { + assertThatCode(() -> new OAuth2IntrospectionAuthenticationToken(null, null, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("token cannot be null"); + } + + @Test + public void constructorWhenAttributesAreNullOrEmptyThenThrowsException() { + assertThatCode(() -> new OAuth2IntrospectionAuthenticationToken(this.token, null, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("principal cannot be null"); + + assertThatCode(() -> new OAuth2IntrospectionAuthenticationToken(this.token, Collections.emptyMap(), null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("attributes cannot be empty"); + } + + @Test + public void constructorWhenPassingAllAttributesThenTokenIsAuthenticated() { + OAuth2IntrospectionAuthenticationToken authenticated = + new OAuth2IntrospectionAuthenticationToken(this.token, Collections.singletonMap("claim", "value"), + Collections.emptyList(), "harris"); + assertThat(authenticated.isAuthenticated()).isTrue(); + } + + @Test + public void getTokenAttributesWhenHasTokenThenReturnsThem() { + OAuth2IntrospectionAuthenticationToken authenticated = + new OAuth2IntrospectionAuthenticationToken(this.token, this.attributes, Collections.emptyList()); + assertThat(authenticated.getTokenAttributes()).isEqualTo(this.attributes); + } + + @Test + public void getAuthoritiesWhenHasAuthoritiesThenReturnsThem() { + List authorities = AuthorityUtils.createAuthorityList("USER"); + OAuth2IntrospectionAuthenticationToken authenticated = + new OAuth2IntrospectionAuthenticationToken(this.token, this.attributes, authorities); + assertThat(authenticated.getAuthorities()).isEqualTo(authorities); + } +}