4 changed files with 838 additions and 86 deletions
@ -0,0 +1,270 @@
@@ -0,0 +1,270 @@
|
||||
/* |
||||
* Copyright 2002-2019 the original author or authors. |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
package org.springframework.security.oauth2.server.resource.authentication; |
||||
|
||||
import java.net.URI; |
||||
import java.net.URL; |
||||
import java.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; |
||||
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.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; |
||||
|
||||
/** |
||||
* An {@link ReactiveAuthenticationManager} implementation for opaque |
||||
* <a href="https://tools.ietf.org/html/rfc6750#section-1.2" target="_blank">Bearer Token</a>s, |
||||
* using an |
||||
* <a href="https://tools.ietf.org/html/rfc7662" target="_blank">OAuth 2.0 Introspection Endpoint</a> |
||||
* to check the token's validity and reveal its attributes. |
||||
* <p> |
||||
* This {@link ReactiveAuthenticationManager} is responsible for introspecting and verifying an opaque access token, |
||||
* returning its attributes set as part of the {@see Authentication} statement. |
||||
* <p> |
||||
* Scopes are translated into {@link GrantedAuthority}s according to the following algorithm: |
||||
* <ol> |
||||
* <li> |
||||
* If there is a "scope" attribute, then convert to a {@link Collection} of {@link String}s. |
||||
* <li> |
||||
* Take the resulting {@link Collection} and prepend the "SCOPE_" keyword to each element, adding as {@link GrantedAuthority}s. |
||||
* </ol> |
||||
* |
||||
* @author Josh Cummings |
||||
* @since 5.2 |
||||
* @see ReactiveAuthenticationManager |
||||
*/ |
||||
public class OAuth2IntrospectionReactiveAuthenticationManager implements ReactiveAuthenticationManager { |
||||
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 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)); |
||||
} |
||||
|
||||
@Override |
||||
public Mono<Authentication> authenticate(Authentication authentication) { |
||||
return Mono.justOrEmpty(authentication) |
||||
.filter(BearerTokenAuthenticationToken.class::isInstance) |
||||
.cast(BearerTokenAuthenticationToken.class) |
||||
.map(BearerTokenAuthenticationToken::getToken) |
||||
.flatMap(this::authenticate) |
||||
.cast(Authentication.class); |
||||
} |
||||
|
||||
private Mono<OAuth2IntrospectionAuthenticationToken> authenticate(String token) { |
||||
return introspect(token) |
||||
.map(response -> { |
||||
Map<String, Object> claims = convertClaimsSet(response); |
||||
Instant iat = (Instant) claims.get(ISSUED_AT); |
||||
Instant exp = (Instant) claims.get(EXPIRES_AT); |
||||
|
||||
// construct token
|
||||
OAuth2AccessToken accessToken = |
||||
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; |
||||
} |
||||
|
||||
private Collection<GrantedAuthority> extractAuthorities(Map<String, Object> claims) { |
||||
Collection<String> scopes = (Collection<String>) claims.get(SCOPE); |
||||
return Optional.ofNullable(scopes).orElse(Collections.emptyList()) |
||||
.stream() |
||||
.map(authority -> new SimpleGrantedAuthority("SCOPE_" + authority)) |
||||
.collect(Collectors.toList()); |
||||
} |
||||
|
||||
private URL issuer(String uri) { |
||||
try { |
||||
return new URL(uri); |
||||
} catch (Exception ex) { |
||||
throw new OAuth2AuthenticationException( |
||||
invalidToken("Invalid " + ISSUER + " value: " + uri), ex); |
||||
} |
||||
} |
||||
|
||||
private static BearerTokenError invalidToken(String message) { |
||||
return new BearerTokenError("invalid_token", |
||||
HttpStatus.UNAUTHORIZED, message, |
||||
"https://tools.ietf.org/html/rfc7662#section-2.2"); |
||||
} |
||||
|
||||
|
||||
private OAuth2AuthenticationException onError(Throwable e) { |
||||
OAuth2Error invalidToken = invalidToken(e.getMessage()); |
||||
return new OAuth2AuthenticationException(invalidToken, e.getMessage()); |
||||
} |
||||
} |
||||
@ -0,0 +1,310 @@
@@ -0,0 +1,310 @@
|
||||
/* |
||||
* Copyright 2002-2019 the original author or authors. |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
package org.springframework.security.oauth2.server.resource.authentication; |
||||
|
||||
import java.io.IOException; |
||||
import java.net.URL; |
||||
import java.time.Instant; |
||||
import java.util.Arrays; |
||||
import java.util.Base64; |
||||
import java.util.HashMap; |
||||
import java.util.Map; |
||||
import java.util.Optional; |
||||
|
||||
import net.minidev.json.JSONObject; |
||||
import okhttp3.mockwebserver.Dispatcher; |
||||
import okhttp3.mockwebserver.MockResponse; |
||||
import okhttp3.mockwebserver.MockWebServer; |
||||
import okhttp3.mockwebserver.RecordedRequest; |
||||
import org.junit.Test; |
||||
import 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.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.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; |
||||
|
||||
/** |
||||
* 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); |
||||
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); |
||||
|
||||
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("aud")) |
||||
.containsEntry(NOT_BEFORE, Instant.ofEpochSecond(29348723984L)) |
||||
.doesNotContainKey(OAuth2IntrospectionClaimNames.CLIENT_ID) |
||||
.doesNotContainKey(SCOPE); |
||||
|
||||
assertThat(result.getAuthorities()).isEmpty(); |
||||
} |
||||
|
||||
@Test |
||||
public void authenticateWhenIntrospectionEndpointThrowsExceptionThenInvalidToken() { |
||||
WebClient webClient = mockResponse(new IllegalStateException("server was unresponsive")); |
||||
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 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"); |
||||
} |
||||
|
||||
@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)) |
||||
.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); |
||||
} |
||||
} |
||||
Loading…
Reference in new issue