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