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 extends GrantedAuthority> 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:
+ *
+ * -
+ * If there is a "scope" attribute, then convert to a {@link Collection} of {@link String}s.
+ *
-
+ * 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 extends GrantedAuthority> 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 extends GrantedAuthority> 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);
+ }
+}