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 eb13966cd5e..ea468369f1e 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.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.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 @@
*
*
*
+ *
+ * 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) {
+ JwtDecoder decoder = this.jwtConfigurer.getJwtDecoder();
+ Converter jwtAuthenticationConverter =
+ this.jwtConfigurer.getJwtAuthenticationConverter();
+
+ JwtAuthenticationProvider provider =
+ new JwtAuthenticationProvider(decoder);
+ provider.setJwtAuthenticationConverter(jwtAuthenticationConverter);
+ provider = postProcess(provider);
- JwtAuthenticationProvider provider =
- new JwtAuthenticationProvider(decoder);
- provider.setJwtAuthenticationConverter(jwtAuthenticationConverter);
- provider = postProcess(provider);
+ http.authenticationProvider(provider);
+ }
- http.authenticationProvider(provider);
+ if (this.opaqueTokenConfigurer != null) {
+ http.authenticationProvider(this.opaqueTokenConfigurer.getProvider());
+ }
}
public class JwtConfigurer {
@@ -248,6 +274,31 @@ JwtDecoder getJwtDecoder() {
}
}
+ public class OpaqueTokenConfigurer {
+ private String introspectionUri;
+ private String introspectionClientId;
+ private String introspectionClientSecret;
+
+ public OpaqueTokenConfigurer introspectionUri(String introspectionUri) {
+ Assert.notNull(introspectionUri, "introspectionUri cannot be null");
+ this.introspectionUri = introspectionUri;
+ 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;
+ return this;
+ }
+
+ AuthenticationProvider getProvider() {
+ return new OAuth2IntrospectionAuthenticationProvider(this.introspectionUri,
+ this.introspectionClientId, this.introspectionClientSecret);
+ }
+ }
+
private void registerDefaultAccessDeniedHandler(H http) {
ExceptionHandlingConfigurer 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 2abf49d5590..2eb6c0aad36 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 void configuredWhenMissingJwtAuthenticationProviderThenWiringException()
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 void configureWhenMissingJwkSetUriThenWiringException() {
.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 @@ JwtDecoder decoder() throws Exception {
}
}
+ @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 7fac327e252..10159c9a8df 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 896ac031d86..6a4a231d85b 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 @@
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 @@ protected AbstractOAuth2TokenAuthenticationToken(
T token,
Collection extends GrantedAuthority> 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 @@ protected AbstractOAuth2TokenAuthenticationToken(
*/
@Override
public Object getPrincipal() {
- return this.getToken();
+ return this.principal;
}
/**
@@ -83,7 +96,7 @@ public Object getPrincipal() {
*/
@Override
public Object getCredentials() {
- return this.getToken();
+ return this.credentials;
}
/**
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
new file mode 100644
index 00000000000..5e080a3296c
--- /dev/null
+++ b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionAuthenticationProvider.java
@@ -0,0 +1,283 @@
+/*
+ * 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.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;
+import org.springframework.security.core.AuthenticationException;
+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.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;
+
+/**
+ * An {@link AuthenticationProvider} implementation for opaque
+ * Bearer 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 00000000000..eb2d5d44bce
--- /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 00000000000..d6eb91c0b6e
--- /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 00000000000..98cbfcb1b45
--- /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 00000000000..0f565120062
--- /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);
+ }
+}
diff --git a/samples/boot/oauth2authorizationserver/src/main/java/sample/JwkSetConfiguration.java b/samples/boot/oauth2authorizationserver/src/main/java/sample/AuthorizationServerConfiguration.java
similarity index 77%
rename from samples/boot/oauth2authorizationserver/src/main/java/sample/JwkSetConfiguration.java
rename to samples/boot/oauth2authorizationserver/src/main/java/sample/AuthorizationServerConfiguration.java
index bca7df06079..593793b7cad 100644
--- a/samples/boot/oauth2authorizationserver/src/main/java/sample/JwkSetConfiguration.java
+++ b/samples/boot/oauth2authorizationserver/src/main/java/sample/AuthorizationServerConfiguration.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.
@@ -21,12 +21,15 @@
import java.security.interfaces.RSAPublicKey;
import java.security.spec.RSAPrivateKeySpec;
import java.security.spec.RSAPublicKeySpec;
+import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
+import java.util.stream.Collectors;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
+import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
@@ -37,18 +40,23 @@
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
+import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer;
+import org.springframework.security.oauth2.provider.OAuth2Authentication;
import org.springframework.security.oauth2.provider.endpoint.FrameworkEndpoint;
import org.springframework.security.oauth2.provider.token.DefaultAccessTokenConverter;
import org.springframework.security.oauth2.provider.token.DefaultUserAuthenticationConverter;
import org.springframework.security.oauth2.provider.token.TokenStore;
+import org.springframework.security.oauth2.provider.token.store.InMemoryTokenStore;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;
import org.springframework.security.oauth2.provider.token.store.JwtTokenStore;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
/**
@@ -66,17 +74,20 @@
*/
@EnableAuthorizationServer
@Configuration
-public class JwkSetConfiguration extends AuthorizationServerConfigurerAdapter {
+public class AuthorizationServerConfiguration extends AuthorizationServerConfigurerAdapter {
AuthenticationManager authenticationManager;
KeyPair keyPair;
+ boolean jwtEnabled;
- public JwkSetConfiguration(
+ public AuthorizationServerConfiguration(
AuthenticationConfiguration authenticationConfiguration,
- KeyPair keyPair) throws Exception {
+ KeyPair keyPair,
+ @Value("${security.oauth2.authorizationserver.jwt.enabled:true}") boolean jwtEnabled) throws Exception {
this.authenticationManager = authenticationConfiguration.getAuthenticationManager();
this.keyPair = keyPair;
+ this.jwtEnabled = jwtEnabled;
}
@Override
@@ -94,23 +105,37 @@ public void configure(ClientDetailsServiceConfigurer clients)
.authorizedGrantTypes("password")
.secret("{noop}secret")
.scopes("message:write")
+ .accessTokenValiditySeconds(600_000_000)
+ .and()
+ .withClient("noscopes")
+ .authorizedGrantTypes("password")
+ .secret("{noop}secret")
+ .scopes("none")
.accessTokenValiditySeconds(600_000_000);
// @formatter:on
}
@Override
- public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
+ public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
// @formatter:off
endpoints
.authenticationManager(this.authenticationManager)
- .accessTokenConverter(accessTokenConverter())
.tokenStore(tokenStore());
+
+ if (this.jwtEnabled) {
+ endpoints
+ .accessTokenConverter(accessTokenConverter());
+ }
// @formatter:on
}
@Bean
public TokenStore tokenStore() {
- return new JwtTokenStore(accessTokenConverter());
+ if (this.jwtEnabled) {
+ return new JwtTokenStore(accessTokenConverter());
+ } else {
+ return new InMemoryTokenStore();
+ }
}
@Bean
@@ -137,7 +162,11 @@ protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.mvcMatchers("/.well-known/jwks.json").permitAll()
- .anyRequest().authenticated();
+ .anyRequest().authenticated()
+ .and()
+ .httpBasic()
+ .and()
+ .csrf().ignoringRequestMatchers(request -> "/introspect".equals(request.getRequestURI()));
}
@Bean
@@ -152,6 +181,41 @@ public UserDetailsService userDetailsService() {
}
}
+/**
+ * Legacy Authorization Server (spring-security-oauth2) does not support any
+ * Token Introspection endpoint.
+ *
+ * This class adds ad-hoc support in order to better support the other samples in the repo.
+ */
+@FrameworkEndpoint
+class IntrospectEndpoint {
+ TokenStore tokenStore;
+
+ public IntrospectEndpoint(TokenStore tokenStore) {
+ this.tokenStore = tokenStore;
+ }
+
+ @PostMapping("/introspect")
+ @ResponseBody
+ public Map introspect(@RequestParam("token") String token) {
+ OAuth2AccessToken accessToken = this.tokenStore.readAccessToken(token);
+ Map attributes = new HashMap<>();
+ if (accessToken == null || accessToken.isExpired()) {
+ attributes.put("active", false);
+ return attributes;
+ }
+
+ OAuth2Authentication authentication = this.tokenStore.readAuthentication(token);
+
+ attributes.put("active", true);
+ attributes.put("exp", accessToken.getExpiration().getTime());
+ attributes.put("scope", accessToken.getScope().stream().collect(Collectors.joining(" ")));
+ attributes.put("sub", authentication.getName());
+
+ return attributes;
+ }
+}
+
/**
* Legacy Authorization Server (spring-security-oauth2) does not support any
* JWK Set endpoint.
diff --git a/samples/boot/oauth2authorizationserver/src/main/resources/application.yml b/samples/boot/oauth2authorizationserver/src/main/resources/application.yml
index e5ba667fd3d..b0b10a294f5 100644
--- a/samples/boot/oauth2authorizationserver/src/main/resources/application.yml
+++ b/samples/boot/oauth2authorizationserver/src/main/resources/application.yml
@@ -1 +1,3 @@
server.port: 8081
+
+# security.oauth2.authorizationserver.jwt.enabled: false
diff --git a/samples/boot/oauth2resourceserver-opaque/README.adoc b/samples/boot/oauth2resourceserver-opaque/README.adoc
new file mode 100644
index 00000000000..fc6add9a1f3
--- /dev/null
+++ b/samples/boot/oauth2resourceserver-opaque/README.adoc
@@ -0,0 +1,114 @@
+= OAuth 2.0 Resource Server Sample
+
+This sample demonstrates integrating Resource Server with a mock Authorization Server, though it can be modified to integrate
+with your favorite Authorization Server.
+
+With it, you can run the integration tests or run the application as a stand-alone service to explore how you can
+secure your own service with OAuth 2.0 Opaque Bearer Tokens using Spring Security.
+
+== 1. Running the tests
+
+To run the tests, do:
+
+```bash
+./gradlew integrationTest
+```
+
+Or import the project into your IDE and run `OAuth2ResourceServerApplicationTests` from there.
+
+=== What is it doing?
+
+By default, the tests are pointing at a mock Authorization Server instance.
+
+The tests are configured with a set of hard-coded tokens originally obtained from the mock Authorization Server,
+and each makes a query to the Resource Server with their corresponding token.
+
+The Resource Server subsquently verifies with the Authorization Server and authorizes the request, returning the phrase
+
+```bash
+Hello, subject!
+```
+
+where "subject" is the value of the `sub` field in the JWT returned by the Authorization Server.
+
+== 2. Running the app
+
+To run as a stand-alone application, do:
+
+```bash
+./gradlew bootRun
+```
+
+Or import the project into your IDE and run `OAuth2ResourceServerApplication` from there.
+
+Once it is up, you can use the following token:
+
+```bash
+export TOKEN=00ed5855-1869-47a0-b0c9-0f3ce520aee7
+```
+
+And then make this request:
+
+```bash
+curl -H "Authorization: Bearer $TOKEN" localhost:8080
+```
+
+Which will respond with the phrase:
+
+```bash
+Hello, subject!
+```
+
+where `subject` is the value of the `sub` field in the JWT returned by the Authorization Server.
+
+Or this:
+
+```bash
+export TOKEN=b43d1500-c405-4dc9-b9c9-6cfd966c34c9
+
+curl -H "Authorization: Bearer $TOKEN" localhost:8080/message
+```
+
+Will respond with:
+
+```bash
+secret message
+```
+
+== 2. Testing against other Authorization Servers
+
+_In order to use this sample, your Authorization Server must support Opaque Tokens and the Introspection Endpoint.
+
+To change the sample to point at your Authorization Server, simply find this property in the `application.yml`:
+
+```yaml
+spring:
+ security:
+ oauth2:
+ resourceserver:
+ opaque:
+ introspection-uri: ${mockwebserver.url}/introspect
+ introspection-client-id: client
+ introspection-client-secret: secret
+```
+
+And change the property to your Authorization Server's Introspection endpoint, including its client id and secret:
+
+```yaml
+spring:
+ security:
+ oauth2:
+ resourceserver:
+ opaque:
+ introspection-uri: ${mockwebserver.url}/introspect
+```
+
+And then you can run the app the same as before:
+
+```bash
+./gradlew bootRun
+```
+
+Make sure to obtain valid tokens from your Authorization Server in order to play with the sample Resource Server.
+To use the `/` endpoint, any valid token from your Authorization Server will do.
+To use the `/message` endpoint, the token should have the `message:read` scope.
diff --git a/samples/boot/oauth2resourceserver-opaque/spring-security-samples-boot-oauth2resourceserver-opaque.gradle b/samples/boot/oauth2resourceserver-opaque/spring-security-samples-boot-oauth2resourceserver-opaque.gradle
new file mode 100644
index 00000000000..9074842b18a
--- /dev/null
+++ b/samples/boot/oauth2resourceserver-opaque/spring-security-samples-boot-oauth2resourceserver-opaque.gradle
@@ -0,0 +1,14 @@
+apply plugin: 'io.spring.convention.spring-sample-boot'
+
+dependencies {
+ compile project(':spring-security-config')
+ compile project(':spring-security-oauth2-jose')
+ compile project(':spring-security-oauth2-resource-server')
+
+ compile 'org.springframework.boot:spring-boot-starter-web'
+ compile 'com.nimbusds:oauth2-oidc-sdk'
+ compile 'com.squareup.okhttp3:mockwebserver'
+
+ testCompile project(':spring-security-test')
+ testCompile 'org.springframework.boot:spring-boot-starter-test'
+}
diff --git a/samples/boot/oauth2resourceserver-opaque/src/integration-test/java/sample/OAuth2ResourceServerApplicationITests.java b/samples/boot/oauth2resourceserver-opaque/src/integration-test/java/sample/OAuth2ResourceServerApplicationITests.java
new file mode 100644
index 00000000000..02bfcda689a
--- /dev/null
+++ b/samples/boot/oauth2resourceserver-opaque/src/integration-test/java/sample/OAuth2ResourceServerApplicationITests.java
@@ -0,0 +1,101 @@
+/*
+ * 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 sample;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.http.HttpHeaders;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.junit4.SpringRunner;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.request.RequestPostProcessor;
+
+import static org.hamcrest.Matchers.containsString;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * Integration tests for {@link OAuth2ResourceServerApplication}
+ *
+ * @author Josh Cummings
+ */
+@RunWith(SpringRunner.class)
+@SpringBootTest
+@AutoConfigureMockMvc
+@ActiveProfiles("test")
+public class OAuth2ResourceServerApplicationITests {
+
+ String noScopesToken = "00ed5855-1869-47a0-b0c9-0f3ce520aee7";
+ String messageReadToken = "b43d1500-c405-4dc9-b9c9-6cfd966c34c9";
+
+ @Autowired
+ MockMvc mvc;
+
+ @Test
+ public void performWhenValidBearerTokenThenAllows()
+ throws Exception {
+
+ this.mvc.perform(get("/").with(bearerToken(this.noScopesToken)))
+ .andExpect(status().isOk())
+ .andExpect(content().string(containsString("Hello, subject!")));
+ }
+
+ // -- tests with scopes
+
+ @Test
+ public void performWhenValidBearerTokenThenScopedRequestsAlsoWork()
+ throws Exception {
+
+ this.mvc.perform(get("/message").with(bearerToken(this.messageReadToken)))
+ .andExpect(status().isOk())
+ .andExpect(content().string(containsString("secret message")));
+ }
+
+ @Test
+ public void performWhenInsufficientlyScopedBearerTokenThenDeniesScopedMethodAccess()
+ throws Exception {
+
+ this.mvc.perform(get("/message").with(bearerToken(this.noScopesToken)))
+ .andExpect(status().isForbidden())
+ .andExpect(header().string(HttpHeaders.WWW_AUTHENTICATE,
+ containsString("Bearer error=\"insufficient_scope\"")));
+ }
+
+ private static class BearerTokenRequestPostProcessor implements RequestPostProcessor {
+ private String token;
+
+ public BearerTokenRequestPostProcessor(String token) {
+ this.token = token;
+ }
+
+ @Override
+ public MockHttpServletRequest postProcessRequest(MockHttpServletRequest request) {
+ request.addHeader("Authorization", "Bearer " + this.token);
+ return request;
+ }
+ }
+
+ private static BearerTokenRequestPostProcessor bearerToken(String token) {
+ return new BearerTokenRequestPostProcessor(token);
+ }
+}
diff --git a/samples/boot/oauth2resourceserver-opaque/src/main/java/org/springframework/boot/env/MockWebServerEnvironmentPostProcessor.java b/samples/boot/oauth2resourceserver-opaque/src/main/java/org/springframework/boot/env/MockWebServerEnvironmentPostProcessor.java
new file mode 100644
index 00000000000..0900dd9740b
--- /dev/null
+++ b/samples/boot/oauth2resourceserver-opaque/src/main/java/org/springframework/boot/env/MockWebServerEnvironmentPostProcessor.java
@@ -0,0 +1,41 @@
+/*
+ * 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.boot.env;
+
+import org.springframework.beans.factory.DisposableBean;
+import org.springframework.boot.SpringApplication;
+import org.springframework.core.env.ConfigurableEnvironment;
+
+/**
+ * @author Rob Winch
+ */
+public class MockWebServerEnvironmentPostProcessor
+ implements EnvironmentPostProcessor, DisposableBean {
+
+ private final MockWebServerPropertySource propertySource = new MockWebServerPropertySource();
+
+ @Override
+ public void postProcessEnvironment(ConfigurableEnvironment environment,
+ SpringApplication application) {
+ environment.getPropertySources().addFirst(this.propertySource);
+ }
+
+ @Override
+ public void destroy() throws Exception {
+ this.propertySource.destroy();
+ }
+}
diff --git a/samples/boot/oauth2resourceserver-opaque/src/main/java/org/springframework/boot/env/MockWebServerPropertySource.java b/samples/boot/oauth2resourceserver-opaque/src/main/java/org/springframework/boot/env/MockWebServerPropertySource.java
new file mode 100644
index 00000000000..03a6e51785b
--- /dev/null
+++ b/samples/boot/oauth2resourceserver-opaque/src/main/java/org/springframework/boot/env/MockWebServerPropertySource.java
@@ -0,0 +1,181 @@
+/*
+ * 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.boot.env;
+
+import java.io.IOException;
+import java.util.Base64;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import okhttp3.mockwebserver.Dispatcher;
+import okhttp3.mockwebserver.MockResponse;
+import okhttp3.mockwebserver.MockWebServer;
+import okhttp3.mockwebserver.RecordedRequest;
+import okio.Buffer;
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+
+import org.springframework.beans.factory.DisposableBean;
+import org.springframework.core.env.PropertySource;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+
+/**
+ * @author Rob Winch
+ */
+public class MockWebServerPropertySource extends PropertySource implements
+ DisposableBean {
+
+ private static final MockResponse NO_SCOPES_RESPONSE = response(
+ "{\n" +
+ " \"active\": true,\n" +
+ " \"sub\": \"subject\"\n" +
+ " }",
+ 200
+ );
+
+ private static final MockResponse MESSASGE_READ_SCOPE_RESPONSE = response(
+ "{\n" +
+ " \"active\": true,\n" +
+ " \"scope\" : \"message:read\"," +
+ " \"sub\": \"subject\"\n" +
+ " }",
+ 200
+ );
+
+ private static final MockResponse INACTIVE_RESPONSE = response(
+ "{\n" +
+ " \"active\": false,\n" +
+ " }",
+ 200
+ );
+
+ private static final MockResponse BAD_REQUEST_RESPONSE = response(
+ "{ \"message\" : \"This mock authorization server requires a username and password of " +
+ "client/secret and a POST body of token=${token}\" }",
+ 400
+ );
+
+ private static final MockResponse NOT_FOUND_RESPONSE = response(
+ "{ \"message\" : \"This mock authorization server responds to just one request: POST /introspect.\" }",
+ 404
+ );
+
+ /**
+ * Name of the random {@link PropertySource}.
+ */
+ public static final String MOCK_WEB_SERVER_PROPERTY_SOURCE_NAME = "mockwebserver";
+
+ private static final String NAME = "mockwebserver.url";
+
+ private static final Log logger = LogFactory.getLog(MockWebServerPropertySource.class);
+
+ private boolean started;
+
+ public MockWebServerPropertySource() {
+ super(MOCK_WEB_SERVER_PROPERTY_SOURCE_NAME, new MockWebServer());
+ }
+
+ @Override
+ public Object getProperty(String name) {
+ if (!name.equals(NAME)) {
+ return null;
+ }
+ if (logger.isTraceEnabled()) {
+ logger.trace("Looking up the url for '" + name + "'");
+ }
+ String url = getUrl();
+ return url;
+ }
+
+ @Override
+ public void destroy() throws Exception {
+ getSource().shutdown();
+ }
+
+ /**
+ * Get's the URL (e.g. "http://localhost:123456")
+ * @return
+ */
+ private String getUrl() {
+ MockWebServer mockWebServer = getSource();
+ if (!this.started) {
+ initializeMockWebServer(mockWebServer);
+ }
+ String url = mockWebServer.url("").url().toExternalForm();
+ return url.substring(0, url.length() - 1);
+ }
+
+ private void initializeMockWebServer(MockWebServer mockWebServer) {
+ Dispatcher dispatcher = new Dispatcher() {
+ @Override
+ public MockResponse dispatch(RecordedRequest request) {
+ return doDispatch(request);
+ }
+ };
+
+ mockWebServer.setDispatcher(dispatcher);
+ try {
+ mockWebServer.start();
+ this.started = true;
+ } catch (IOException e) {
+ throw new RuntimeException("Could not start " + mockWebServer, e);
+ }
+ }
+
+ private MockResponse doDispatch(RecordedRequest request) {
+ if ("/introspect".equals(request.getPath())) {
+ return Optional.ofNullable(request.getHeader(HttpHeaders.AUTHORIZATION))
+ .filter(authorization -> isAuthorized(authorization, "client", "secret"))
+ .map(authorization -> parseBody(request.getBody()))
+ .map(parameters -> parameters.get("token"))
+ .map(token -> {
+ if ("00ed5855-1869-47a0-b0c9-0f3ce520aee7".equals(token)) {
+ return NO_SCOPES_RESPONSE;
+ } else if ("b43d1500-c405-4dc9-b9c9-6cfd966c34c9".equals(token)) {
+ return MESSASGE_READ_SCOPE_RESPONSE;
+ } else {
+ return INACTIVE_RESPONSE;
+ }
+ })
+ .orElse(BAD_REQUEST_RESPONSE);
+ }
+
+ return NOT_FOUND_RESPONSE;
+ }
+
+ private 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 Map parseBody(Buffer body) {
+ return Stream.of(body.readUtf8().split("&"))
+ .map(parameter -> parameter.split("="))
+ .collect(Collectors.toMap(parts -> parts[0], parts -> parts[1]));
+ }
+
+ private static MockResponse response(String body, int status) {
+ return new MockResponse()
+ .setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
+ .setResponseCode(status)
+ .setBody(body);
+ }
+
+}
diff --git a/samples/boot/oauth2resourceserver-opaque/src/main/java/org/springframework/boot/env/package-info.java b/samples/boot/oauth2resourceserver-opaque/src/main/java/org/springframework/boot/env/package-info.java
new file mode 100644
index 00000000000..67d99c793de
--- /dev/null
+++ b/samples/boot/oauth2resourceserver-opaque/src/main/java/org/springframework/boot/env/package-info.java
@@ -0,0 +1,23 @@
+/*
+ * 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.
+ */
+
+/**
+ * This provides integration of a {@link okhttp3.mockwebserver.MockWebServer} and the
+ * {@link org.springframework.core.env.Environment}
+ *
+ * @author Rob Winch
+ */
+package org.springframework.boot.env;
diff --git a/samples/boot/oauth2resourceserver-opaque/src/main/java/sample/OAuth2ResourceServerApplication.java b/samples/boot/oauth2resourceserver-opaque/src/main/java/sample/OAuth2ResourceServerApplication.java
new file mode 100644
index 00000000000..465cd0af888
--- /dev/null
+++ b/samples/boot/oauth2resourceserver-opaque/src/main/java/sample/OAuth2ResourceServerApplication.java
@@ -0,0 +1,30 @@
+/*
+ * 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 sample;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+/**
+ * @author Josh Cummings
+ */
+@SpringBootApplication
+public class OAuth2ResourceServerApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(OAuth2ResourceServerApplication.class, args);
+ }
+}
diff --git a/samples/boot/oauth2resourceserver-opaque/src/main/java/sample/OAuth2ResourceServerController.java b/samples/boot/oauth2resourceserver-opaque/src/main/java/sample/OAuth2ResourceServerController.java
new file mode 100644
index 00000000000..32be749f287
--- /dev/null
+++ b/samples/boot/oauth2resourceserver-opaque/src/main/java/sample/OAuth2ResourceServerController.java
@@ -0,0 +1,37 @@
+/*
+ * 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 sample;
+
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * @author Josh Cummings
+ */
+@RestController
+public class OAuth2ResourceServerController {
+
+ @GetMapping("/")
+ public String index(@AuthenticationPrincipal(expression="['sub']") String subject) {
+ return String.format("Hello, %s!", subject);
+ }
+
+ @GetMapping("/message")
+ public String message() {
+ return "secret message";
+ }
+}
diff --git a/samples/boot/oauth2resourceserver-opaque/src/main/java/sample/OAuth2ResourceServerSecurityConfiguration.java b/samples/boot/oauth2resourceserver-opaque/src/main/java/sample/OAuth2ResourceServerSecurityConfiguration.java
new file mode 100644
index 00000000000..23ed4db21d2
--- /dev/null
+++ b/samples/boot/oauth2resourceserver-opaque/src/main/java/sample/OAuth2ResourceServerSecurityConfiguration.java
@@ -0,0 +1,47 @@
+/*
+ * 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 sample;
+
+import org.springframework.beans.factory.annotation.Value;
+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;
+
+/**
+ * @author Josh Cummings
+ */
+@EnableWebSecurity
+public class OAuth2ResourceServerSecurityConfiguration extends WebSecurityConfigurerAdapter {
+
+ @Value("${spring.security.oauth2.resourceserver.opaque.introspection-uri}") String introspectionUri;
+ @Value("${spring.security.oauth2.resourceserver.opaque.introspection-client-id}") String clientId;
+ @Value("${spring.security.oauth2.resourceserver.opaque.introspection-client-secret}") String clientSecret;
+
+ @Override
+ protected void configure(HttpSecurity http) throws Exception {
+ // @formatter:off
+ http
+ .authorizeRequests()
+ .mvcMatchers("/message/**").hasAuthority("SCOPE_message:read")
+ .anyRequest().authenticated()
+ .and()
+ .oauth2ResourceServer()
+ .opaqueToken()
+ .introspectionUri(this.introspectionUri)
+ .introspectionClientCredentials(this.clientId, this.clientSecret);
+ // @formatter:on
+ }
+}
diff --git a/samples/boot/oauth2resourceserver-opaque/src/main/resources/META-INF/spring.factories b/samples/boot/oauth2resourceserver-opaque/src/main/resources/META-INF/spring.factories
new file mode 100644
index 00000000000..37b447c9702
--- /dev/null
+++ b/samples/boot/oauth2resourceserver-opaque/src/main/resources/META-INF/spring.factories
@@ -0,0 +1 @@
+org.springframework.boot.env.EnvironmentPostProcessor=org.springframework.boot.env.MockWebServerEnvironmentPostProcessor
diff --git a/samples/boot/oauth2resourceserver-opaque/src/main/resources/application.yml b/samples/boot/oauth2resourceserver-opaque/src/main/resources/application.yml
new file mode 100644
index 00000000000..a7dcfead944
--- /dev/null
+++ b/samples/boot/oauth2resourceserver-opaque/src/main/resources/application.yml
@@ -0,0 +1,8 @@
+spring:
+ security:
+ oauth2:
+ resourceserver:
+ opaque:
+ introspection-uri: ${mockwebserver.url}/introspect
+ introspection-client-id: client
+ introspection-client-secret: secret