From 198b195427adb280646e008d3b7ad323ef3bb114 Mon Sep 17 00:00:00 2001 From: Tran Ngoc Nhan Date: Sun, 22 Jun 2025 01:42:55 +0700 Subject: [PATCH] Remove Nimbus(Reactive)OpaqueTokenIntrospector Signed-off-by: Tran Ngoc Nhan --- .../OAuth2ResourceServerConfigurerTests.java | 6 +- ...sourceServerBeanDefinitionParserTests.java | 4 +- .../resourceserver/OpaqueTokenDslTests.kt | 24 +- .../web/server/ServerOpaqueTokenDslTests.kt | 8 +- .../NimbusOpaqueTokenIntrospector.java | 269 ------------ ...NimbusReactiveOpaqueTokenIntrospector.java | 240 ----------- .../NimbusOpaqueTokenIntrospectorTests.java | 383 ------------------ ...sReactiveOpaqueTokenIntrospectorTests.java | 331 --------------- 8 files changed, 21 insertions(+), 1244 deletions(-) delete mode 100644 oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/NimbusOpaqueTokenIntrospector.java delete mode 100644 oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/NimbusReactiveOpaqueTokenIntrospector.java delete mode 100644 oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/introspection/NimbusOpaqueTokenIntrospectorTests.java delete mode 100644 oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/introspection/NimbusReactiveOpaqueTokenIntrospectorTests.java diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests.java index d49520c56e..50ae85c9c3 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests.java @@ -121,9 +121,9 @@ import org.springframework.security.oauth2.server.resource.authentication.Bearer import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; import org.springframework.security.oauth2.server.resource.authentication.JwtIssuerAuthenticationManagerResolver; -import org.springframework.security.oauth2.server.resource.introspection.NimbusOpaqueTokenIntrospector; import org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenAuthenticationConverter; import org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector; +import org.springframework.security.oauth2.server.resource.introspection.SpringOpaqueTokenIntrospector; import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint; import org.springframework.security.oauth2.server.resource.web.BearerTokenResolver; import org.springframework.security.oauth2.server.resource.web.DefaultBearerTokenResolver; @@ -2729,8 +2729,8 @@ public class OAuth2ResourceServerConfigurerTests { } @Bean - NimbusOpaqueTokenIntrospector tokenIntrospectionClient() { - return new NimbusOpaqueTokenIntrospector("https://example.org/introspect", this.rest); + OpaqueTokenIntrospector tokenIntrospectionClient() { + return new SpringOpaqueTokenIntrospector("https://example.org/introspect", this.rest); } } diff --git a/config/src/test/java/org/springframework/security/config/http/OAuth2ResourceServerBeanDefinitionParserTests.java b/config/src/test/java/org/springframework/security/config/http/OAuth2ResourceServerBeanDefinitionParserTests.java index 5ad167eef8..400ff28613 100644 --- a/config/src/test/java/org/springframework/security/config/http/OAuth2ResourceServerBeanDefinitionParserTests.java +++ b/config/src/test/java/org/springframework/security/config/http/OAuth2ResourceServerBeanDefinitionParserTests.java @@ -84,9 +84,9 @@ import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; import org.springframework.security.oauth2.jwt.TestJwts; import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthenticationToken; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; -import org.springframework.security.oauth2.server.resource.introspection.NimbusOpaqueTokenIntrospector; import org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenAuthenticationConverter; import org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector; +import org.springframework.security.oauth2.server.resource.introspection.SpringOpaqueTokenIntrospector; import org.springframework.security.oauth2.server.resource.web.BearerTokenResolver; import org.springframework.security.test.context.annotation.SecurityTestExecutionListeners; import org.springframework.security.web.authentication.AuthenticationConverter; @@ -1047,7 +1047,7 @@ public class OAuth2ResourceServerBeanDefinitionParserTests { @Override public OpaqueTokenIntrospector getObject() throws Exception { - return new NimbusOpaqueTokenIntrospector("https://idp.example.org", this.rest); + return new SpringOpaqueTokenIntrospector("https://idp.example.org", this.rest); } @Override diff --git a/config/src/test/kotlin/org/springframework/security/config/annotation/web/oauth2/resourceserver/OpaqueTokenDslTests.kt b/config/src/test/kotlin/org/springframework/security/config/annotation/web/oauth2/resourceserver/OpaqueTokenDslTests.kt index 25ebe73f28..7e5970e7df 100644 --- a/config/src/test/kotlin/org/springframework/security/config/annotation/web/oauth2/resourceserver/OpaqueTokenDslTests.kt +++ b/config/src/test/kotlin/org/springframework/security/config/annotation/web/oauth2/resourceserver/OpaqueTokenDslTests.kt @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2025 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. @@ -24,6 +24,7 @@ import org.junit.jupiter.api.extension.ExtendWith import org.springframework.beans.factory.annotation.Autowired import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration +import org.springframework.core.ParameterizedTypeReference import org.springframework.http.HttpHeaders import org.springframework.http.HttpStatus import org.springframework.http.MediaType @@ -41,7 +42,6 @@ import org.springframework.security.oauth2.core.DefaultOAuth2AuthenticatedPrinci import org.springframework.security.oauth2.core.TestOAuth2AccessTokens import org.springframework.security.oauth2.jwt.JwtClaimNames import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthentication -import org.springframework.security.oauth2.server.resource.introspection.NimbusOpaqueTokenIntrospector import org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector import org.springframework.security.oauth2.server.resource.introspection.SpringOpaqueTokenIntrospector import org.springframework.security.web.SecurityFilterChain @@ -84,15 +84,15 @@ class OpaqueTokenDslTests { val headers = HttpHeaders().apply { contentType = MediaType.APPLICATION_JSON } - val entity = ResponseEntity("{\n" + - " \"active\" : true,\n" + - " \"sub\": \"test-subject\",\n" + - " \"scope\": \"message:read\",\n" + - " \"exp\": 4683883211\n" + - "}", headers, HttpStatus.OK) + val responseBody: Map = mapOf( + "active" to true, + "sub" to "test-subject", + "scope" to "message:read", + "exp" to 4683883211 + ) every { - DefaultOpaqueConfig.REST.exchange(any(), eq(String::class.java)) - } returns entity + DefaultOpaqueConfig.REST.exchange(any(), any>>()) + } returns ResponseEntity(responseBody, headers, HttpStatus.OK) this.mockMvc.get("/authenticated") { header("Authorization", "Bearer token") @@ -127,8 +127,8 @@ class OpaqueTokenDslTests { open fun rest(): RestOperations = REST @Bean - open fun tokenIntrospectionClient(): NimbusOpaqueTokenIntrospector { - return NimbusOpaqueTokenIntrospector("https://example.org/introspect", REST) + open fun tokenIntrospectionClient(): OpaqueTokenIntrospector { + return SpringOpaqueTokenIntrospector("https://example.org/introspect", REST) } } diff --git a/config/src/test/kotlin/org/springframework/security/config/web/server/ServerOpaqueTokenDslTests.kt b/config/src/test/kotlin/org/springframework/security/config/web/server/ServerOpaqueTokenDslTests.kt index 3128214c61..387b60dc5c 100644 --- a/config/src/test/kotlin/org/springframework/security/config/web/server/ServerOpaqueTokenDslTests.kt +++ b/config/src/test/kotlin/org/springframework/security/config/web/server/ServerOpaqueTokenDslTests.kt @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2025 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. @@ -28,8 +28,8 @@ import org.springframework.http.HttpHeaders import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity import org.springframework.security.config.test.SpringTestContext import org.springframework.security.config.test.SpringTestContextExtension -import org.springframework.security.oauth2.server.resource.introspection.NimbusReactiveOpaqueTokenIntrospector import org.springframework.security.oauth2.server.resource.introspection.ReactiveOpaqueTokenIntrospector +import org.springframework.security.oauth2.server.resource.introspection.SpringReactiveOpaqueTokenIntrospector import org.springframework.security.web.server.SecurityWebFilterChain import org.springframework.test.web.reactive.server.WebTestClient import org.springframework.web.reactive.config.EnableWebFlux @@ -103,7 +103,7 @@ class ServerOpaqueTokenDslTests { @Bean open fun tokenIntrospectionClient(): ReactiveOpaqueTokenIntrospector { - return NimbusReactiveOpaqueTokenIntrospector(mockWebServer().url("/introspect").toString(), "client", "secret") + return SpringReactiveOpaqueTokenIntrospector(mockWebServer().url("/introspect").toString(), "client", "secret") } } @@ -138,7 +138,7 @@ class ServerOpaqueTokenDslTests { } oauth2ResourceServer { opaqueToken { - introspector = NimbusReactiveOpaqueTokenIntrospector(mockWebServer().url("/introspector").toString(), "client", "secret") + introspector = SpringReactiveOpaqueTokenIntrospector(mockWebServer().url("/introspector").toString(), "client", "secret") } } } diff --git a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/NimbusOpaqueTokenIntrospector.java b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/NimbusOpaqueTokenIntrospector.java deleted file mode 100644 index d12fee4eed..0000000000 --- a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/NimbusOpaqueTokenIntrospector.java +++ /dev/null @@ -1,269 +0,0 @@ -/* - * Copyright 2002-2021 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.time.Instant; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.List; -import java.util.Map; - -import com.nimbusds.oauth2.sdk.ErrorObject; -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.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; - -import org.springframework.core.convert.converter.Converter; -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.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.oauth2.core.OAuth2AuthenticatedPrincipal; -import org.springframework.security.oauth2.core.OAuth2TokenIntrospectionClaimNames; -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; - -/** - * A Nimbus implementation of {@link OpaqueTokenIntrospector} that verifies and - * introspects a token using the configured - * OAuth 2.0 Introspection - * Endpoint. - * - * @author Josh Cummings - * @author MD Sayem Ahmed - * @since 5.2 - * @deprecated Please use {@link SpringOpaqueTokenIntrospector} instead - */ -@Deprecated -public class NimbusOpaqueTokenIntrospector implements OpaqueTokenIntrospector { - - private static final String AUTHORITY_PREFIX = "SCOPE_"; - - private final Log logger = LogFactory.getLog(getClass()); - - private final RestOperations restOperations; - - private Converter> requestEntityConverter; - - /** - * Creates a {@code OpaqueTokenAuthenticationProvider} 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 NimbusOpaqueTokenIntrospector(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.requestEntityConverter = this.defaultRequestEntityConverter(URI.create(introspectionUri)); - RestTemplate restTemplate = new RestTemplate(); - restTemplate.getInterceptors().add(new BasicAuthenticationInterceptor(clientId, clientSecret)); - this.restOperations = restTemplate; - } - - /** - * Creates a {@code OpaqueTokenAuthenticationProvider} 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 NimbusOpaqueTokenIntrospector(String introspectionUri, RestOperations restOperations) { - Assert.notNull(introspectionUri, "introspectionUri cannot be null"); - Assert.notNull(restOperations, "restOperations cannot be null"); - this.requestEntityConverter = this.defaultRequestEntityConverter(URI.create(introspectionUri)); - this.restOperations = restOperations; - } - - private Converter> defaultRequestEntityConverter(URI introspectionUri) { - return (token) -> { - HttpHeaders headers = requestHeaders(); - MultiValueMap body = requestBody(token); - return new RequestEntity<>(body, headers, HttpMethod.POST, introspectionUri); - }; - } - - private HttpHeaders requestHeaders() { - HttpHeaders headers = new HttpHeaders(); - headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); - return headers; - } - - private MultiValueMap requestBody(String token) { - MultiValueMap body = new LinkedMultiValueMap<>(); - body.add("token", token); - return body; - } - - @Override - public OAuth2AuthenticatedPrincipal introspect(String token) { - RequestEntity requestEntity = this.requestEntityConverter.convert(token); - if (requestEntity == null) { - throw new OAuth2IntrospectionException("requestEntityConverter returned a null entity"); - } - ResponseEntity responseEntity = makeRequest(requestEntity); - HTTPResponse httpResponse = adaptToNimbusResponse(responseEntity); - TokenIntrospectionResponse introspectionResponse = parseNimbusResponse(httpResponse); - TokenIntrospectionSuccessResponse introspectionSuccessResponse = castToNimbusSuccess(introspectionResponse); - // relying solely on the authorization server to validate this token (not checking - // 'exp', for example) - if (!introspectionSuccessResponse.isActive()) { - this.logger.trace("Did not validate token since it is inactive"); - throw new BadOpaqueTokenException("Provided token isn't active"); - } - return convertClaimsSet(introspectionSuccessResponse); - } - - /** - * Sets the {@link Converter} used for converting the OAuth 2.0 access token to a - * {@link RequestEntity} representation of the OAuth 2.0 token introspection request. - * @param requestEntityConverter the {@link Converter} used for converting to a - * {@link RequestEntity} representation of the token introspection request - */ - public void setRequestEntityConverter(Converter> requestEntityConverter) { - Assert.notNull(requestEntityConverter, "requestEntityConverter cannot be null"); - this.requestEntityConverter = requestEntityConverter; - } - - private ResponseEntity makeRequest(RequestEntity requestEntity) { - try { - return this.restOperations.exchange(requestEntity, String.class); - } - catch (Exception ex) { - throw new OAuth2IntrospectionException(ex.getMessage(), ex); - } - } - - private HTTPResponse adaptToNimbusResponse(ResponseEntity responseEntity) { - MediaType contentType = responseEntity.getHeaders().getContentType(); - - if (contentType == null) { - this.logger.trace("Did not receive Content-Type from introspection endpoint in response"); - - throw new OAuth2IntrospectionException( - "Introspection endpoint response was invalid, as no Content-Type header was provided"); - } - - // Nimbus expects JSON, but does not appear to validate this header first. - if (!contentType.isCompatibleWith(MediaType.APPLICATION_JSON)) { - this.logger.trace("Did not receive JSON-compatible Content-Type from introspection endpoint in response"); - - throw new OAuth2IntrospectionException("Introspection endpoint response was invalid, as content type '" - + contentType + "' is not compatible with JSON"); - } - - HTTPResponse response = new HTTPResponse(responseEntity.getStatusCode().value()); - response.setHeader(HttpHeaders.CONTENT_TYPE, contentType.toString()); - response.setContent(responseEntity.getBody()); - - if (response.getStatusCode() != HTTPResponse.SC_OK) { - this.logger.trace("Introspection endpoint returned non-OK status code"); - - throw new OAuth2IntrospectionException( - "Introspection endpoint responded with HTTP status code " + 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()) { - ErrorObject errorObject = introspectionResponse.toErrorResponse().getErrorObject(); - String message = "Token introspection failed with response " + errorObject.toJSONObject().toJSONString(); - this.logger.trace(message); - throw new OAuth2IntrospectionException(message); - } - return (TokenIntrospectionSuccessResponse) introspectionResponse; - } - - private OAuth2AuthenticatedPrincipal convertClaimsSet(TokenIntrospectionSuccessResponse response) { - Collection authorities = new ArrayList<>(); - Map claims = response.toJSONObject(); - if (response.getAudience() != null) { - List audiences = new ArrayList<>(); - for (Audience audience : response.getAudience()) { - audiences.add(audience.getValue()); - } - claims.put(OAuth2TokenIntrospectionClaimNames.AUD, Collections.unmodifiableList(audiences)); - } - if (response.getClientID() != null) { - claims.put(OAuth2TokenIntrospectionClaimNames.CLIENT_ID, response.getClientID().getValue()); - } - if (response.getExpirationTime() != null) { - Instant exp = response.getExpirationTime().toInstant(); - claims.put(OAuth2TokenIntrospectionClaimNames.EXP, exp); - } - if (response.getIssueTime() != null) { - Instant iat = response.getIssueTime().toInstant(); - claims.put(OAuth2TokenIntrospectionClaimNames.IAT, iat); - } - if (response.getIssuer() != null) { - // RFC-7662 page 7 directs users to RFC-7519 for defining the values of these - // issuer fields. - // https://datatracker.ietf.org/doc/html/rfc7662#page-7 - // - // RFC-7519 page 9 defines issuer fields as being 'case-sensitive' strings - // containing - // a 'StringOrURI', which is defined on page 5 as being any string, but - // strings containing ':' - // should be treated as valid URIs. - // https://datatracker.ietf.org/doc/html/rfc7519#section-2 - // - // It is not defined however as to whether-or-not normalized URIs should be - // treated as the same literal - // value. It only defines validation itself, so to avoid potential ambiguity - // or unwanted side effects that - // may be awkward to debug, we do not want to manipulate this value. Previous - // versions of Spring Security - // would *only* allow valid URLs, which is not what we wish to achieve here. - claims.put(OAuth2TokenIntrospectionClaimNames.ISS, response.getIssuer().getValue()); - } - if (response.getNotBeforeTime() != null) { - claims.put(OAuth2TokenIntrospectionClaimNames.NBF, response.getNotBeforeTime().toInstant()); - } - if (response.getScope() != null) { - List scopes = Collections.unmodifiableList(response.getScope().toStringList()); - claims.put(OAuth2TokenIntrospectionClaimNames.SCOPE, scopes); - for (String scope : scopes) { - authorities.add(new SimpleGrantedAuthority(AUTHORITY_PREFIX + scope)); - } - } - return new OAuth2IntrospectionAuthenticatedPrincipal(claims, authorities); - } - -} diff --git a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/NimbusReactiveOpaqueTokenIntrospector.java b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/NimbusReactiveOpaqueTokenIntrospector.java deleted file mode 100644 index 6593248360..0000000000 --- a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/NimbusReactiveOpaqueTokenIntrospector.java +++ /dev/null @@ -1,240 +0,0 @@ -/* - * Copyright 2002-2021 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.time.Instant; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.List; -import java.util.Map; - -import com.nimbusds.oauth2.sdk.ErrorObject; -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.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; -import reactor.core.publisher.Mono; - -import org.springframework.core.io.buffer.DataBuffer; -import org.springframework.core.io.buffer.DataBufferUtils; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.oauth2.core.OAuth2AuthenticatedPrincipal; -import org.springframework.security.oauth2.core.OAuth2TokenIntrospectionClaimNames; -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; - -/** - * A Nimbus implementation of {@link ReactiveOpaqueTokenIntrospector} that verifies and - * introspects a token using the configured - * OAuth 2.0 Introspection - * Endpoint. - * - * @author Josh Cummings - * @since 5.2 - * @deprecated Please use {@link SpringReactiveOpaqueTokenIntrospector} instead - */ -@Deprecated -public class NimbusReactiveOpaqueTokenIntrospector implements ReactiveOpaqueTokenIntrospector { - - private static final String AUTHORITY_PREFIX = "SCOPE_"; - - private final Log logger = LogFactory.getLog(getClass()); - - private final URI introspectionUri; - - private final WebClient webClient; - - /** - * Creates a {@code OpaqueTokenReactiveAuthenticationManager} 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 NimbusReactiveOpaqueTokenIntrospector(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 OpaqueTokenReactiveAuthenticationManager} with the provided - * parameters - * @param introspectionUri The introspection endpoint uri - * @param webClient The client for performing the introspection request - */ - public NimbusReactiveOpaqueTokenIntrospector(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; - } - - @Override - public Mono introspect(String token) { - // @formatter:off - return this.makeRequest(token) - .exchangeToMono(this::adaptToNimbusResponse) - .map(this::parseNimbusResponse) - .map(this::castToNimbusSuccess) - .doOnNext((response) -> validate(token, response)) - .map(this::convertClaimsSet) - .onErrorMap((e) -> !(e instanceof OAuth2IntrospectionException), this::onError); - // @formatter:on - } - - private WebClient.RequestHeadersSpec makeRequest(String token) { - // @formatter:off - return this.webClient.post() - .uri(this.introspectionUri) - .header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) - .body(BodyInserters.fromFormData("token", token)); - // @formatter:on - } - - private Mono adaptToNimbusResponse(ClientResponse responseEntity) { - MediaType contentType = responseEntity.headers().contentType().orElseThrow(() -> { - this.logger.trace("Did not receive Content-Type from introspection endpoint in response"); - - return new OAuth2IntrospectionException( - "Introspection endpoint response was invalid, as no Content-Type header was provided"); - }); - - // Nimbus expects JSON, but does not appear to validate this header first. - if (!contentType.isCompatibleWith(MediaType.APPLICATION_JSON)) { - this.logger.trace("Did not receive JSON-compatible Content-Type from introspection endpoint in response"); - - throw new OAuth2IntrospectionException("Introspection endpoint response was invalid, as content type '" - + contentType + "' is not compatible with JSON"); - } - - HTTPResponse response = new HTTPResponse(responseEntity.statusCode().value()); - response.setHeader(HttpHeaders.CONTENT_TYPE, contentType.toString()); - if (response.getStatusCode() != HTTPResponse.SC_OK) { - this.logger.trace("Introspection endpoint returned non-OK status code"); - - // @formatter:off - return responseEntity.bodyToFlux(DataBuffer.class) - .map(DataBufferUtils::release) - .then(Mono.error(new OAuth2IntrospectionException( - "Introspection endpoint responded with HTTP status code " + response.getStatusCode())) - ); - // @formatter:on - } - 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()) { - ErrorObject errorObject = introspectionResponse.toErrorResponse().getErrorObject(); - String message = "Token introspection failed with response " + errorObject.toJSONObject().toJSONString(); - this.logger.trace(message); - throw new OAuth2IntrospectionException(message); - } - 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()) { - this.logger.trace("Did not validate token since it is inactive"); - throw new BadOpaqueTokenException("Provided token isn't active"); - } - } - - private OAuth2AuthenticatedPrincipal convertClaimsSet(TokenIntrospectionSuccessResponse response) { - Map claims = response.toJSONObject(); - Collection authorities = new ArrayList<>(); - if (response.getAudience() != null) { - List audiences = new ArrayList<>(); - for (Audience audience : response.getAudience()) { - audiences.add(audience.getValue()); - } - claims.put(OAuth2TokenIntrospectionClaimNames.AUD, Collections.unmodifiableList(audiences)); - } - if (response.getClientID() != null) { - claims.put(OAuth2TokenIntrospectionClaimNames.CLIENT_ID, response.getClientID().getValue()); - } - if (response.getExpirationTime() != null) { - Instant exp = response.getExpirationTime().toInstant(); - claims.put(OAuth2TokenIntrospectionClaimNames.EXP, exp); - } - if (response.getIssueTime() != null) { - Instant iat = response.getIssueTime().toInstant(); - claims.put(OAuth2TokenIntrospectionClaimNames.IAT, iat); - } - if (response.getIssuer() != null) { - // RFC-7662 page 7 directs users to RFC-7519 for defining the values of these - // issuer fields. - // https://datatracker.ietf.org/doc/html/rfc7662#page-7 - // - // RFC-7519 page 9 defines issuer fields as being 'case-sensitive' strings - // containing - // a 'StringOrURI', which is defined on page 5 as being any string, but - // strings containing ':' - // should be treated as valid URIs. - // https://datatracker.ietf.org/doc/html/rfc7519#section-2 - // - // It is not defined however as to whether-or-not normalized URIs should be - // treated as the same literal - // value. It only defines validation itself, so to avoid potential ambiguity - // or unwanted side effects that - // may be awkward to debug, we do not want to manipulate this value. Previous - // versions of Spring Security - // would *only* allow valid URLs, which is not what we wish to achieve here. - claims.put(OAuth2TokenIntrospectionClaimNames.ISS, response.getIssuer().getValue()); - } - if (response.getNotBeforeTime() != null) { - claims.put(OAuth2TokenIntrospectionClaimNames.NBF, response.getNotBeforeTime().toInstant()); - } - if (response.getScope() != null) { - List scopes = Collections.unmodifiableList(response.getScope().toStringList()); - claims.put(OAuth2TokenIntrospectionClaimNames.SCOPE, scopes); - - for (String scope : scopes) { - authorities.add(new SimpleGrantedAuthority(AUTHORITY_PREFIX + scope)); - } - } - return new OAuth2IntrospectionAuthenticatedPrincipal(claims, authorities); - } - - private OAuth2IntrospectionException onError(Throwable ex) { - return new OAuth2IntrospectionException(ex.getMessage(), ex); - } - -} diff --git a/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/introspection/NimbusOpaqueTokenIntrospectorTests.java b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/introspection/NimbusOpaqueTokenIntrospectorTests.java deleted file mode 100644 index 1177c44390..0000000000 --- a/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/introspection/NimbusOpaqueTokenIntrospectorTests.java +++ /dev/null @@ -1,383 +0,0 @@ -/* - * Copyright 2002-2021 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.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.JSONArray; -import net.minidev.json.JSONObject; -import okhttp3.mockwebserver.Dispatcher; -import okhttp3.mockwebserver.MockResponse; -import okhttp3.mockwebserver.MockWebServer; -import okhttp3.mockwebserver.RecordedRequest; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; - -import org.springframework.core.convert.converter.Converter; -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.core.OAuth2AuthenticatedPrincipal; -import org.springframework.security.oauth2.core.OAuth2TokenIntrospectionClaimNames; -import org.springframework.web.client.RestOperations; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatExceptionOfType; -import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; -import static org.assertj.core.api.Assumptions.assumeThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; - -/** - * Tests for {@link NimbusOpaqueTokenIntrospector} - */ -public class NimbusOpaqueTokenIntrospectorTests { - - private static final String INTROSPECTION_URL = "https://server.example.com"; - - private static final String CLIENT_ID = "client"; - - private static final String CLIENT_SECRET = "secret"; - - // @formatter:off - 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" - + " }"; - // @formatter:on - - // @formatter:off - private static final String INACTIVE_RESPONSE = "{\n" - + " \"active\": false\n" - + " }"; - // @formatter:on - - // @formatter:off - 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" - + " }"; - // @formatter:on - - // @formatter:off - private static final String MALFORMED_ISSUER_RESPONSE = "{\n" - + " \"active\" : \"true\",\n" - + " \"iss\" : \"badissuer\"\n" - + " }"; - // @formatter:on - - // @formatter:off - private static final String MALFORMED_SCOPE_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" - + " }"; - // @formatter:on - - private static final ResponseEntity ACTIVE = response(ACTIVE_RESPONSE); - - private static final ResponseEntity INACTIVE = response(INACTIVE_RESPONSE); - - private static final ResponseEntity INVALID = response(INVALID_RESPONSE); - - private static final ResponseEntity MALFORMED_ISSUER = response(MALFORMED_ISSUER_RESPONSE); - - private static final ResponseEntity MALFORMED_SCOPE = response(MALFORMED_SCOPE_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(); - OpaqueTokenIntrospector introspectionClient = new NimbusOpaqueTokenIntrospector(introspectUri, CLIENT_ID, - CLIENT_SECRET); - OAuth2AuthenticatedPrincipal authority = introspectionClient.introspect("token"); - // @formatter:off - assertThat(authority.getAttributes()) - .isNotNull() - .containsEntry(OAuth2TokenIntrospectionClaimNames.ACTIVE, true) - .containsEntry(OAuth2TokenIntrospectionClaimNames.AUD, - Arrays.asList("https://protected.example.net/resource")) - .containsEntry(OAuth2TokenIntrospectionClaimNames.CLIENT_ID, "l238j323ds-23ij4") - .containsEntry(OAuth2TokenIntrospectionClaimNames.EXP, Instant.ofEpochSecond(1419356238)) - .containsEntry(OAuth2TokenIntrospectionClaimNames.ISS, "https://server.example.com/") - .containsEntry(OAuth2TokenIntrospectionClaimNames.SCOPE, Arrays.asList("read", "write", "dolphin")) - .containsEntry(OAuth2TokenIntrospectionClaimNames.SUB, "Z5O3upPC88QrAjx00dis") - .containsEntry(OAuth2TokenIntrospectionClaimNames.USERNAME, "jdoe") - .containsEntry("extension_field", "twenty-seven"); - // @formatter:on - } - } - - @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(); - OpaqueTokenIntrospector introspectionClient = new NimbusOpaqueTokenIntrospector(introspectUri, CLIENT_ID, - "wrong"); - assertThatExceptionOfType(OAuth2IntrospectionException.class) - .isThrownBy(() -> introspectionClient.introspect("token")); - } - } - - @Test - public void introspectWhenInactiveTokenThenInvalidToken() { - RestOperations restOperations = mock(RestOperations.class); - OpaqueTokenIntrospector introspectionClient = new NimbusOpaqueTokenIntrospector(INTROSPECTION_URL, - restOperations); - given(restOperations.exchange(any(RequestEntity.class), eq(String.class))).willReturn(INACTIVE); - // @formatter:off - assertThatExceptionOfType(OAuth2IntrospectionException.class) - .isThrownBy(() -> introspectionClient.introspect("token")) - .withMessage("Provided token isn't active"); - // @formatter:on - } - - @Test - public void introspectWhenActiveTokenThenParsesValuesInResponse() { - Map introspectedValues = new HashMap<>(); - introspectedValues.put(OAuth2TokenIntrospectionClaimNames.ACTIVE, true); - introspectedValues.put(OAuth2TokenIntrospectionClaimNames.AUD, Arrays.asList("aud")); - introspectedValues.put(OAuth2TokenIntrospectionClaimNames.NBF, 29348723984L); - RestOperations restOperations = mock(RestOperations.class); - OpaqueTokenIntrospector introspectionClient = new NimbusOpaqueTokenIntrospector(INTROSPECTION_URL, - restOperations); - given(restOperations.exchange(any(RequestEntity.class), eq(String.class))) - .willReturn(response(new JSONObject(introspectedValues).toJSONString())); - OAuth2AuthenticatedPrincipal authority = introspectionClient.introspect("token"); - // @formatter:off - assertThat(authority.getAttributes()) - .isNotNull() - .containsEntry(OAuth2TokenIntrospectionClaimNames.ACTIVE, true) - .containsEntry(OAuth2TokenIntrospectionClaimNames.AUD, Arrays.asList("aud")) - .containsEntry(OAuth2TokenIntrospectionClaimNames.NBF, Instant.ofEpochSecond(29348723984L)) - .doesNotContainKey(OAuth2TokenIntrospectionClaimNames.CLIENT_ID) - .doesNotContainKey(OAuth2TokenIntrospectionClaimNames.SCOPE); - // @formatter:on - } - - @Test - public void introspectWhenIntrospectionEndpointThrowsExceptionThenInvalidToken() { - RestOperations restOperations = mock(RestOperations.class); - OpaqueTokenIntrospector introspectionClient = new NimbusOpaqueTokenIntrospector(INTROSPECTION_URL, - restOperations); - given(restOperations.exchange(any(RequestEntity.class), eq(String.class))) - .willThrow(new IllegalStateException("server was unresponsive")); - // @formatter:off - assertThatExceptionOfType(OAuth2IntrospectionException.class) - .isThrownBy(() -> introspectionClient.introspect("token")) - .withMessage("server was unresponsive"); - // @formatter:on - } - - @Test - public void introspectWhenIntrospectionEndpointReturnsMalformedResponseThenInvalidToken() { - RestOperations restOperations = mock(RestOperations.class); - OpaqueTokenIntrospector introspectionClient = new NimbusOpaqueTokenIntrospector(INTROSPECTION_URL, - restOperations); - given(restOperations.exchange(any(RequestEntity.class), eq(String.class))).willReturn(response("malformed")); - assertThatExceptionOfType(OAuth2IntrospectionException.class) - .isThrownBy(() -> introspectionClient.introspect("token")); - } - - @Test - public void introspectWhenIntrospectionTokenReturnsInvalidResponseThenInvalidToken() { - RestOperations restOperations = mock(RestOperations.class); - OpaqueTokenIntrospector introspectionClient = new NimbusOpaqueTokenIntrospector(INTROSPECTION_URL, - restOperations); - given(restOperations.exchange(any(RequestEntity.class), eq(String.class))).willReturn(INVALID); - assertThatExceptionOfType(OAuth2IntrospectionException.class) - .isThrownBy(() -> introspectionClient.introspect("token")); - } - - @Test - public void introspectWhenIntrospectionTokenReturnsMalformedIssuerResponseThenInvalidToken() { - RestOperations restOperations = mock(RestOperations.class); - OpaqueTokenIntrospector introspectionClient = new NimbusOpaqueTokenIntrospector(INTROSPECTION_URL, - restOperations); - given(restOperations.exchange(any(RequestEntity.class), eq(String.class))).willReturn(MALFORMED_ISSUER); - assertThatExceptionOfType(OAuth2IntrospectionException.class) - .isThrownBy(() -> introspectionClient.introspect("token")); - } - - // gh-7563 - @Test - public void introspectWhenIntrospectionTokenReturnsMalformedScopeThenEmptyAuthorities() { - RestOperations restOperations = mock(RestOperations.class); - OpaqueTokenIntrospector introspectionClient = new NimbusOpaqueTokenIntrospector(INTROSPECTION_URL, - restOperations); - given(restOperations.exchange(any(RequestEntity.class), eq(String.class))).willReturn(MALFORMED_SCOPE); - OAuth2AuthenticatedPrincipal principal = introspectionClient.introspect("token"); - assertThat(principal.getAuthorities()).isEmpty(); - JSONArray scope = principal.getAttribute("scope"); - assertThat(scope).containsExactly("read", "write", "dolphin"); - } - - @Test - public void constructorWhenIntrospectionUriIsNullThenIllegalArgumentException() { - assertThatIllegalArgumentException() - .isThrownBy(() -> new NimbusOpaqueTokenIntrospector(null, CLIENT_ID, CLIENT_SECRET)); - } - - @Test - public void constructorWhenClientIdIsNullThenIllegalArgumentException() { - assertThatIllegalArgumentException() - .isThrownBy(() -> new NimbusOpaqueTokenIntrospector(INTROSPECTION_URL, null, CLIENT_SECRET)); - } - - @Test - public void constructorWhenClientSecretIsNullThenIllegalArgumentException() { - assertThatIllegalArgumentException() - .isThrownBy(() -> new NimbusOpaqueTokenIntrospector(INTROSPECTION_URL, CLIENT_ID, null)); - } - - @Test - public void constructorWhenRestOperationsIsNullThenIllegalArgumentException() { - assertThatIllegalArgumentException() - .isThrownBy(() -> new NimbusOpaqueTokenIntrospector(INTROSPECTION_URL, null)); - } - - @Test - public void setRequestEntityConverterWhenConverterIsNullThenExceptionIsThrown() { - RestOperations restOperations = mock(RestOperations.class); - NimbusOpaqueTokenIntrospector introspectionClient = new NimbusOpaqueTokenIntrospector(INTROSPECTION_URL, - restOperations); - assertThatExceptionOfType(IllegalArgumentException.class) - .isThrownBy(() -> introspectionClient.setRequestEntityConverter(null)); - } - - @SuppressWarnings("unchecked") - @Test - public void setRequestEntityConverterWhenNonNullConverterGivenThenConverterUsed() { - RestOperations restOperations = mock(RestOperations.class); - Converter> requestEntityConverter = mock(Converter.class); - RequestEntity requestEntity = mock(RequestEntity.class); - String tokenToIntrospect = "some token"; - given(requestEntityConverter.convert(tokenToIntrospect)).willReturn(requestEntity); - given(restOperations.exchange(requestEntity, String.class)).willReturn(ACTIVE); - NimbusOpaqueTokenIntrospector introspectionClient = new NimbusOpaqueTokenIntrospector(INTROSPECTION_URL, - restOperations); - introspectionClient.setRequestEntityConverter(requestEntityConverter); - introspectionClient.introspect(tokenToIntrospect); - verify(requestEntityConverter).convert(tokenToIntrospect); - } - - @Test - public void handleMissingContentType() { - RestOperations restOperations = mock(RestOperations.class); - ResponseEntity stubResponse = ResponseEntity.ok(ACTIVE_RESPONSE); - given(restOperations.exchange(any(RequestEntity.class), eq(String.class))).willReturn(stubResponse); - OpaqueTokenIntrospector introspectionClient = new NimbusOpaqueTokenIntrospector(INTROSPECTION_URL, - restOperations); - - // Protect against potential regressions where a default content type might be - // added by default. - assumeThat(stubResponse.getHeaders().getContentType()).isNull(); - - assertThatExceptionOfType(OAuth2IntrospectionException.class) - .isThrownBy(() -> introspectionClient.introspect("sometokenhere")); - } - - @ParameterizedTest(name = "{displayName} when Content-Type={0}") - @ValueSource(strings = { MediaType.APPLICATION_CBOR_VALUE, MediaType.TEXT_MARKDOWN_VALUE, - MediaType.APPLICATION_XML_VALUE, MediaType.APPLICATION_OCTET_STREAM_VALUE }) - public void handleNonJsonContentType(String type) { - RestOperations restOperations = mock(RestOperations.class); - ResponseEntity stubResponse = ResponseEntity.ok() - .contentType(MediaType.parseMediaType(type)) - .body(ACTIVE_RESPONSE); - given(restOperations.exchange(any(RequestEntity.class), eq(String.class))).willReturn(stubResponse); - OpaqueTokenIntrospector introspectionClient = new NimbusOpaqueTokenIntrospector(INTROSPECTION_URL, - restOperations); - - assertThatExceptionOfType(OAuth2IntrospectionException.class) - .isThrownBy(() -> introspectionClient.introspect("sometokenhere")); - } - - private static ResponseEntity response(String content) { - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - return new ResponseEntity<>(content, headers, HttpStatus.OK); - } - - private static Dispatcher requiresAuth(String username, String password, String response) { - return new Dispatcher() { - @Override - public MockResponse dispatch(RecordedRequest request) { - String authorization = request.getHeader(HttpHeaders.AUTHORIZATION); - // @formatter:off - return Optional.ofNullable(authorization) - .filter((a) -> isAuthorized(authorization, username, password)) - .map((a) -> ok(response)) - .orElse(unauthorized()); - // @formatter:on - } - }; - } - - 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) { - // @formatter:off - return new MockResponse().setBody(response) - .setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE); - // @formatter:on - } - - private static MockResponse unauthorized() { - return new MockResponse().setResponseCode(401); - } - -} diff --git a/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/introspection/NimbusReactiveOpaqueTokenIntrospectorTests.java b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/introspection/NimbusReactiveOpaqueTokenIntrospectorTests.java deleted file mode 100644 index 0b8dd246cd..0000000000 --- a/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/introspection/NimbusReactiveOpaqueTokenIntrospectorTests.java +++ /dev/null @@ -1,331 +0,0 @@ -/* - * Copyright 2002-2021 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.time.Instant; -import java.util.Arrays; -import java.util.Base64; -import java.util.HashMap; -import java.util.Map; -import java.util.Optional; -import java.util.function.Function; - -import net.minidev.json.JSONObject; -import okhttp3.mockwebserver.Dispatcher; -import okhttp3.mockwebserver.MockResponse; -import okhttp3.mockwebserver.MockWebServer; -import okhttp3.mockwebserver.RecordedRequest; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; -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.core.OAuth2AuthenticatedPrincipal; -import org.springframework.security.oauth2.core.OAuth2TokenIntrospectionClaimNames; -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.assertThatExceptionOfType; -import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.spy; - -/** - * Tests for {@link NimbusReactiveOpaqueTokenIntrospector} - */ -public class NimbusReactiveOpaqueTokenIntrospectorTests { - - private static final String INTROSPECTION_URL = "https://server.example.com"; - - private static final String CLIENT_ID = "client"; - - private static final String CLIENT_SECRET = "secret"; - - // @formatter:off - 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" - + " }"; - // @formatter:on - - // @formatter:off - private static final String INACTIVE_RESPONSE = "{\n" - + " \"active\": false\n" - + " }"; - // @formatter:on - - // @formatter:off - 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" - + " }"; - // @formatter:on - - // @formatter:off - private static final String MALFORMED_ISSUER_RESPONSE = "{\n" - + " \"active\" : \"true\",\n" - + " \"iss\" : \"badissuer\"\n" - + " }"; - // @formatter:on - - @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(); - NimbusReactiveOpaqueTokenIntrospector introspectionClient = new NimbusReactiveOpaqueTokenIntrospector( - introspectUri, CLIENT_ID, CLIENT_SECRET); - OAuth2AuthenticatedPrincipal authority = introspectionClient.introspect("token").block(); - // @formatter:off - assertThat(authority.getAttributes()) - .isNotNull() - .containsEntry(OAuth2TokenIntrospectionClaimNames.ACTIVE, true) - .containsEntry(OAuth2TokenIntrospectionClaimNames.AUD, - Arrays.asList("https://protected.example.net/resource")) - .containsEntry(OAuth2TokenIntrospectionClaimNames.CLIENT_ID, "l238j323ds-23ij4") - .containsEntry(OAuth2TokenIntrospectionClaimNames.EXP, Instant.ofEpochSecond(1419356238)) - .containsEntry(OAuth2TokenIntrospectionClaimNames.ISS, "https://server.example.com/") - .containsEntry(OAuth2TokenIntrospectionClaimNames.SCOPE, Arrays.asList("read", "write", "dolphin")) - .containsEntry(OAuth2TokenIntrospectionClaimNames.SUB, "Z5O3upPC88QrAjx00dis") - .containsEntry(OAuth2TokenIntrospectionClaimNames.USERNAME, "jdoe") - .containsEntry("extension_field", "twenty-seven"); - // @formatter:on - } - } - - @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(); - NimbusReactiveOpaqueTokenIntrospector introspectionClient = new NimbusReactiveOpaqueTokenIntrospector( - introspectUri, CLIENT_ID, "wrong"); - assertThatExceptionOfType(OAuth2IntrospectionException.class) - .isThrownBy(() -> introspectionClient.introspect("token").block()); - - } - } - - @Test - public void authenticateWhenInactiveTokenThenInvalidToken() { - WebClient webClient = mockResponse(INACTIVE_RESPONSE); - NimbusReactiveOpaqueTokenIntrospector introspectionClient = new NimbusReactiveOpaqueTokenIntrospector( - INTROSPECTION_URL, webClient); - assertThatExceptionOfType(BadOpaqueTokenException.class) - .isThrownBy(() -> introspectionClient.introspect("token").block()) - .withMessage("Provided token isn't active"); - } - - @Test - public void authenticateWhenActiveTokenThenParsesValuesInResponse() { - Map introspectedValues = new HashMap<>(); - introspectedValues.put(OAuth2TokenIntrospectionClaimNames.ACTIVE, true); - introspectedValues.put(OAuth2TokenIntrospectionClaimNames.AUD, Arrays.asList("aud")); - introspectedValues.put(OAuth2TokenIntrospectionClaimNames.NBF, 29348723984L); - WebClient webClient = mockResponse(new JSONObject(introspectedValues).toJSONString()); - NimbusReactiveOpaqueTokenIntrospector introspectionClient = new NimbusReactiveOpaqueTokenIntrospector( - INTROSPECTION_URL, webClient); - OAuth2AuthenticatedPrincipal authority = introspectionClient.introspect("token").block(); - // @formatter:off - assertThat(authority.getAttributes()) - .isNotNull() - .containsEntry(OAuth2TokenIntrospectionClaimNames.ACTIVE, true) - .containsEntry(OAuth2TokenIntrospectionClaimNames.AUD, Arrays.asList("aud")) - .containsEntry(OAuth2TokenIntrospectionClaimNames.NBF, Instant.ofEpochSecond(29348723984L)) - .doesNotContainKey(OAuth2TokenIntrospectionClaimNames.CLIENT_ID) - .doesNotContainKey(OAuth2TokenIntrospectionClaimNames.SCOPE); - // @formatter:on - } - - @Test - public void authenticateWhenIntrospectionEndpointThrowsExceptionThenInvalidToken() { - WebClient webClient = mockResponse(new IllegalStateException("server was unresponsive")); - NimbusReactiveOpaqueTokenIntrospector introspectionClient = new NimbusReactiveOpaqueTokenIntrospector( - INTROSPECTION_URL, webClient); - // @formatter:off - assertThatExceptionOfType(OAuth2IntrospectionException.class) - .isThrownBy(() -> introspectionClient.introspect("token").block()) - .withMessage("server was unresponsive"); - // @formatter:on - } - - @Test - public void authenticateWhenIntrospectionEndpointReturnsMalformedResponseThenInvalidToken() { - WebClient webClient = mockResponse("malformed"); - NimbusReactiveOpaqueTokenIntrospector introspectionClient = new NimbusReactiveOpaqueTokenIntrospector( - INTROSPECTION_URL, webClient); - assertThatExceptionOfType(OAuth2IntrospectionException.class) - .isThrownBy(() -> introspectionClient.introspect("token").block()); - } - - @Test - public void authenticateWhenIntrospectionTokenReturnsInvalidResponseThenInvalidToken() { - WebClient webClient = mockResponse(INVALID_RESPONSE); - NimbusReactiveOpaqueTokenIntrospector introspectionClient = new NimbusReactiveOpaqueTokenIntrospector( - INTROSPECTION_URL, webClient); - // @formatter:off - assertThatExceptionOfType(OAuth2IntrospectionException.class) - .isThrownBy(() -> introspectionClient.introspect("token").block()); - // @formatter:on - } - - @Test - public void authenticateWhenIntrospectionTokenReturnsMalformedIssuerResponseThenInvalidToken() { - WebClient webClient = mockResponse(MALFORMED_ISSUER_RESPONSE); - NimbusReactiveOpaqueTokenIntrospector introspectionClient = new NimbusReactiveOpaqueTokenIntrospector( - INTROSPECTION_URL, webClient); - assertThatExceptionOfType(OAuth2IntrospectionException.class) - .isThrownBy(() -> introspectionClient.introspect("token").block()); - } - - @Test - public void constructorWhenIntrospectionUriIsEmptyThenIllegalArgumentException() { - assertThatIllegalArgumentException() - .isThrownBy(() -> new NimbusReactiveOpaqueTokenIntrospector("", CLIENT_ID, CLIENT_SECRET)); - } - - @Test - public void constructorWhenClientIdIsEmptyThenIllegalArgumentException() { - assertThatIllegalArgumentException() - .isThrownBy(() -> new NimbusReactiveOpaqueTokenIntrospector(INTROSPECTION_URL, "", CLIENT_SECRET)); - } - - @Test - public void constructorWhenClientSecretIsNullThenIllegalArgumentException() { - assertThatIllegalArgumentException() - .isThrownBy(() -> new NimbusReactiveOpaqueTokenIntrospector(INTROSPECTION_URL, CLIENT_ID, null)); - } - - @Test - public void constructorWhenRestOperationsIsNullThenIllegalArgumentException() { - assertThatIllegalArgumentException() - .isThrownBy(() -> new NimbusReactiveOpaqueTokenIntrospector(INTROSPECTION_URL, null)); - } - - @Test - public void handleMissingContentType() { - WebClient client = mockResponse(ACTIVE_RESPONSE, null); - - ReactiveOpaqueTokenIntrospector introspectionClient = new NimbusReactiveOpaqueTokenIntrospector( - INTROSPECTION_URL, client); - - assertThatExceptionOfType(OAuth2IntrospectionException.class) - .isThrownBy(() -> introspectionClient.introspect("sometokenhere").block()); - } - - @ParameterizedTest(name = "{displayName} when Content-Type={0}") - @ValueSource(strings = { MediaType.APPLICATION_CBOR_VALUE, MediaType.TEXT_MARKDOWN_VALUE, - MediaType.APPLICATION_XML_VALUE, MediaType.APPLICATION_OCTET_STREAM_VALUE }) - public void handleNonJsonContentType(String type) { - WebClient client = mockResponse(ACTIVE_RESPONSE, type); - - ReactiveOpaqueTokenIntrospector introspectionClient = new NimbusReactiveOpaqueTokenIntrospector( - INTROSPECTION_URL, client); - - assertThatExceptionOfType(OAuth2IntrospectionException.class) - .isThrownBy(() -> introspectionClient.introspect("sometokenhere").block()); - } - - private WebClient mockResponse(String response) { - return mockResponse(response, MediaType.APPLICATION_JSON_VALUE); - } - - private WebClient mockResponse(String response, String mediaType) { - WebClient.ResponseSpec responseSpec = mock(WebClient.ResponseSpec.class); - WebClient real = WebClient.builder().build(); - WebClient.RequestBodyUriSpec spec = spy(real.post()); - WebClient webClient = spy(WebClient.class); - given(webClient.post()).willReturn(spec); - ClientResponse clientResponse = mock(ClientResponse.class); - given(clientResponse.statusCode()).willReturn(HttpStatus.OK); - given(clientResponse.bodyToMono(String.class)).willReturn(Mono.just(response)); - ClientResponse.Headers headers = mock(ClientResponse.Headers.class); - given(headers.contentType()).willReturn(Optional.ofNullable(mediaType).map(MediaType::parseMediaType)); - given(clientResponse.headers()).willReturn(headers); - given(responseSpec.bodyToMono(ClientResponse.class)).willReturn(Mono.just(clientResponse)); - given(spec.exchangeToMono(any())).willAnswer((invocation) -> { - Object[] args = invocation.getArguments(); - Function> fn = (Function>) args[0]; - return fn.apply(clientResponse); - }); - given(spec.retrieve()).willReturn(responseSpec); - return webClient; - } - - private WebClient mockResponse(Throwable ex) { - WebClient real = WebClient.builder().build(); - WebClient.RequestBodyUriSpec spec = spy(real.post()); - WebClient webClient = spy(WebClient.class); - given(webClient.post()).willReturn(spec); - given(spec.exchangeToMono(any())).willReturn(Mono.error(ex)); - 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); - // @formatter:off - return Optional.ofNullable(authorization) - .filter((a) -> isAuthorized(authorization, username, password)) - .map((a) -> ok(response)) - .orElse(unauthorized()); - // @formatter:on - } - }; - } - - 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) { - // @formatter:off - return new MockResponse().setBody(response) - .setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE); - // @formatter:on - } - - private static MockResponse unauthorized() { - return new MockResponse().setResponseCode(401); - } - -}