From dc94e5e161e92c4a0839314ab1aee6a23db6b9a5 Mon Sep 17 00:00:00 2001 From: Vivek Babu Date: Fri, 29 May 2020 00:10:21 +0530 Subject: [PATCH] Implement Token Revocation Endpoint Closes gh-83 --- .../DefaultOAuth2TokenRevocationService.java | 48 +++++ .../OAuth2TokenRevocationService.java | 34 +++ ...TokenRevocationAuthenticationProvider.java | 99 +++++++++ ...th2TokenRevocationAuthenticationToken.java | 102 +++++++++ .../OAuth2TokenRevocationEndpointFilter.java | 149 +++++++++++++ ...aultOAuth2TokenRevocationServiceTests.java | 92 ++++++++ ...RevocationAuthenticationProviderTests.java | 150 ++++++++++++++ ...kenRevocationAuthenticationTokenTests.java | 88 ++++++++ ...th2TokenRevocationEndpointFilterTests.java | 196 ++++++++++++++++++ 9 files changed, 958 insertions(+) create mode 100644 core/src/main/java/org/springframework/security/oauth2/server/authorization/DefaultOAuth2TokenRevocationService.java create mode 100644 core/src/main/java/org/springframework/security/oauth2/server/authorization/OAuth2TokenRevocationService.java create mode 100644 core/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenRevocationAuthenticationProvider.java create mode 100644 core/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenRevocationAuthenticationToken.java create mode 100644 core/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2TokenRevocationEndpointFilter.java create mode 100644 core/src/test/java/org/springframework/security/oauth2/server/authorization/DefaultOAuth2TokenRevocationServiceTests.java create mode 100644 core/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenRevocationAuthenticationProviderTests.java create mode 100644 core/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenRevocationAuthenticationTokenTests.java create mode 100644 core/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2TokenRevocationEndpointFilterTests.java diff --git a/core/src/main/java/org/springframework/security/oauth2/server/authorization/DefaultOAuth2TokenRevocationService.java b/core/src/main/java/org/springframework/security/oauth2/server/authorization/DefaultOAuth2TokenRevocationService.java new file mode 100644 index 0000000..1bf6808 --- /dev/null +++ b/core/src/main/java/org/springframework/security/oauth2/server/authorization/DefaultOAuth2TokenRevocationService.java @@ -0,0 +1,48 @@ +/* + * Copyright 2020 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.authorization; + +import org.springframework.util.Assert; + +/** + * An {@link OAuth2TokenRevocationService} that revokes tokens. + * + * @author Vivek Babu + * @see OAuth2AuthorizationService + * @since 0.0.1 + */ +public final class DefaultOAuth2TokenRevocationService implements OAuth2TokenRevocationService { + + private OAuth2AuthorizationService authorizationService; + + /** + * Constructs an {@code DefaultOAuth2TokenRevocationService}. + */ + public DefaultOAuth2TokenRevocationService(OAuth2AuthorizationService authorizationService) { + Assert.notNull(authorizationService, "authorizationService cannot be null"); + this.authorizationService = authorizationService; + } + + @Override + public void revoke(String token, TokenType tokenType) { + final OAuth2Authorization authorization = this.authorizationService.findByTokenAndTokenType(token, tokenType); + if (authorization != null) { + final OAuth2Authorization revokedAuthorization = OAuth2Authorization.from(authorization) + .revoked(true).build(); + this.authorizationService.save(revokedAuthorization); + } + } +} diff --git a/core/src/main/java/org/springframework/security/oauth2/server/authorization/OAuth2TokenRevocationService.java b/core/src/main/java/org/springframework/security/oauth2/server/authorization/OAuth2TokenRevocationService.java new file mode 100644 index 0000000..7ad02a4 --- /dev/null +++ b/core/src/main/java/org/springframework/security/oauth2/server/authorization/OAuth2TokenRevocationService.java @@ -0,0 +1,34 @@ +/* + * Copyright 2020 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.authorization; + +/** + * Implementations of this interface are responsible for the revocation of + * OAuth2 tokens. + * + * @author Vivek Babu + * @since 0.0.1 + */ +public interface OAuth2TokenRevocationService { + + /** + * Revokes the given token. + * + * @param token the token to be revoked + * @param tokenType the type of token to be revoked + */ + void revoke(String token, TokenType tokenType); +} diff --git a/core/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenRevocationAuthenticationProvider.java b/core/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenRevocationAuthenticationProvider.java new file mode 100644 index 0000000..8e0cb75 --- /dev/null +++ b/core/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenRevocationAuthenticationProvider.java @@ -0,0 +1,99 @@ +/* + * Copyright 2020 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.authorization.authentication; + +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.server.authorization.OAuth2Authorization; +import org.springframework.security.oauth2.server.authorization.OAuth2TokenRevocationService; +import org.springframework.security.oauth2.server.authorization.TokenType; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; +import org.springframework.util.Assert; + +/** + * An {@link AuthenticationProvider} implementation for the OAuth 2.0 Token Revocation. + * + * @author Vivek Babu + * @since 0.0.1 + * @see OAuth2TokenRevocationAuthenticationToken + * @see OAuth2AuthorizationService + * @see OAuth2TokenRevocationService + * @see Section 2.1 Revocation Request + */ +public class OAuth2TokenRevocationAuthenticationProvider implements AuthenticationProvider { + + private OAuth2AuthorizationService authorizationService; + private OAuth2TokenRevocationService tokenRevocationService; + + /** + * Constructs an {@code OAuth2TokenRevocationAuthenticationProvider} using the provided parameters. + * + * @param authorizationService the authorization service + * @param tokenRevocationService the token revocation service + */ + public OAuth2TokenRevocationAuthenticationProvider(OAuth2AuthorizationService authorizationService, + OAuth2TokenRevocationService tokenRevocationService) { + Assert.notNull(authorizationService, "authorizationService cannot be null"); + Assert.notNull(tokenRevocationService, "tokenRevocationService cannot be null"); + this.authorizationService = authorizationService; + this.tokenRevocationService = tokenRevocationService; + } + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + OAuth2TokenRevocationAuthenticationToken tokenRevocationAuthenticationToken = + (OAuth2TokenRevocationAuthenticationToken) authentication; + + OAuth2ClientAuthenticationToken clientPrincipal = null; + if (OAuth2ClientAuthenticationToken.class.isAssignableFrom(tokenRevocationAuthenticationToken.getPrincipal() + .getClass())) { + clientPrincipal = (OAuth2ClientAuthenticationToken) tokenRevocationAuthenticationToken.getPrincipal(); + } + if (clientPrincipal == null || !clientPrincipal.isAuthenticated()) { + throw new OAuth2AuthenticationException(new OAuth2Error(OAuth2ErrorCodes.INVALID_CLIENT)); + } + + final RegisteredClient registeredClient = clientPrincipal.getRegisteredClient(); + final String tokenTypeHint = tokenRevocationAuthenticationToken.getTokenTypeHint(); + final String token = tokenRevocationAuthenticationToken.getToken(); + final OAuth2Authorization authorization = authorizationService.findByTokenAndTokenType(token, + TokenType.ACCESS_TOKEN); + + OAuth2TokenRevocationAuthenticationToken successfulAuthentication = + new OAuth2TokenRevocationAuthenticationToken(token, registeredClient, tokenTypeHint); + + if (authorization == null) { + return successfulAuthentication; + } + + if (!registeredClient.getClientId().equals(authorization.getRegisteredClientId())) { + throw new OAuth2AuthenticationException(new OAuth2Error(OAuth2ErrorCodes.INVALID_GRANT)); + } + + tokenRevocationService.revoke(token, TokenType.ACCESS_TOKEN); + return successfulAuthentication; + } + + @Override + public boolean supports(Class authentication) { + return OAuth2TokenRevocationAuthenticationToken.class.isAssignableFrom(authentication); + } +} diff --git a/core/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenRevocationAuthenticationToken.java b/core/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenRevocationAuthenticationToken.java new file mode 100644 index 0000000..d42bbf8 --- /dev/null +++ b/core/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenRevocationAuthenticationToken.java @@ -0,0 +1,102 @@ +/* + * Copyright 2020 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.authorization.authentication; + +import org.springframework.lang.Nullable; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.server.authorization.Version; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.util.Assert; + +import java.util.Collections; + +/** + * An {@link Authentication} implementation used for OAuth 2.0 Client Authentication. + * + * @author Vivek Babu + * @since 0.0.1 + * @see AbstractAuthenticationToken + * @see RegisteredClient + * @see OAuth2TokenRevocationAuthenticationProvider + */ +public class OAuth2TokenRevocationAuthenticationToken extends AbstractAuthenticationToken { + private static final long serialVersionUID = Version.SERIAL_VERSION_UID; + private final String tokenTypeHint; + private Authentication clientPrincipal; + private String token; + private RegisteredClient registeredClient; + + public OAuth2TokenRevocationAuthenticationToken(String token, + Authentication clientPrincipal, @Nullable String tokenTypeHint) { + super(Collections.emptyList()); + Assert.notNull(clientPrincipal, "clientPrincipal cannot be null"); + Assert.hasText(token, "token cannot be empty"); + this.token = token; + this.clientPrincipal = clientPrincipal; + this.tokenTypeHint = tokenTypeHint; + } + + public OAuth2TokenRevocationAuthenticationToken(String token, + RegisteredClient registeredClient, @Nullable String tokenTypeHint) { + super(Collections.emptyList()); + Assert.notNull(registeredClient, "registeredClient cannot be null"); + Assert.hasText(token, "token cannot be empty"); + this.token = token; + this.registeredClient = registeredClient; + this.tokenTypeHint = tokenTypeHint; + setAuthenticated(true); + } + + @Override + public Object getPrincipal() { + return this.clientPrincipal != null ? this.clientPrincipal : this.registeredClient + .getClientId(); + } + + @Override + public Object getCredentials() { + return ""; + } + + /** + * Returns the token. + * + * @return the token + */ + public String getToken() { + return this.token; + } + + /** + * Returns the token type hint. + * + * @return the token type hint + */ + public String getTokenTypeHint() { + return tokenTypeHint; + } + + /** + * Returns the {@link RegisteredClient registered client}. + * + * @return the {@link RegisteredClient} + */ + public @Nullable + RegisteredClient getRegisteredClient() { + return this.registeredClient; + } +} diff --git a/core/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2TokenRevocationEndpointFilter.java b/core/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2TokenRevocationEndpointFilter.java new file mode 100644 index 0000000..a8ba267 --- /dev/null +++ b/core/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2TokenRevocationEndpointFilter.java @@ -0,0 +1,149 @@ +/* + * Copyright 2020 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.authorization.web; + +import org.springframework.core.convert.converter.Converter; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.server.ServletServerHttpResponse; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.http.converter.OAuth2ErrorHttpMessageConverter; +import org.springframework.security.oauth2.server.authorization.OAuth2Authorization; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2TokenRevocationAuthenticationToken; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.util.Assert; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +/** + * A {@code Filter} for the OAuth 2.0 Token Revocation, + * which handles the processing of the OAuth 2.0 Token Revocation Request. + * + * @author Vivek Babu + * @see OAuth2AuthorizationService + * @see OAuth2Authorization + * @see Section 2 Token Revocation + * @see Section 2.1 Revocation Request + * @since 0.0.1 + */ +public class OAuth2TokenRevocationEndpointFilter extends OncePerRequestFilter { + + /** + * The default endpoint {@code URI} for token revocation request. + */ + public static final String DEFAULT_TOKEN_REVOCATION_ENDPOINT_URI = "/oauth2/revoke"; + private static final String TOKEN_TYPE_HINT = "token_type_hint"; + private static final String TOKEN = "token"; + private final AntPathRequestMatcher revocationEndpointMatcher; + + private final Converter tokenRevocationAuthenticationConverter = + new OAuth2TokenRevocationEndpointFilter.TokenRevocationAuthenticationConverter(); + private final HttpMessageConverter errorHttpResponseConverter = + new OAuth2ErrorHttpMessageConverter(); + private final AuthenticationManager authenticationManager; + + /** + * Constructs an {@code OAuth2TokenRevocationEndpointFilter} using the provided parameters. + * + * @param authenticationManager the authentication manager + */ + public OAuth2TokenRevocationEndpointFilter(AuthenticationManager authenticationManager) { + this(authenticationManager, DEFAULT_TOKEN_REVOCATION_ENDPOINT_URI); + } + + /** + * Constructs an {@code OAuth2TokenRevocationEndpointFilter} using the provided parameters. + * + * @param authenticationManager the authentication manager + * @param revocationEndpointUri the endpoint {@code URI} for revocation requests + */ + public OAuth2TokenRevocationEndpointFilter(AuthenticationManager authenticationManager, + String revocationEndpointUri) { + Assert.notNull(authenticationManager, "authenticationManager cannot be null"); + Assert.hasText(revocationEndpointUri, "revocationEndpointUri cannot be empty"); + this.authenticationManager = authenticationManager; + this.revocationEndpointMatcher = new AntPathRequestMatcher( + revocationEndpointUri, HttpMethod.POST.name()); + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + if (!this.revocationEndpointMatcher.matches(request)) { + filterChain.doFilter(request, response); + return; + } + + try { + Authentication tokenRevocationRequestAuthentication = + this.tokenRevocationAuthenticationConverter.convert(request); + this.authenticationManager.authenticate(tokenRevocationRequestAuthentication); + } catch (OAuth2AuthenticationException ex) { + SecurityContextHolder.clearContext(); + sendErrorResponse(response, ex.getError()); + } + } + + private void sendErrorResponse(HttpServletResponse response, OAuth2Error error) throws IOException { + ServletServerHttpResponse httpResponse = new ServletServerHttpResponse(response); + httpResponse.setStatusCode(HttpStatus.BAD_REQUEST); + this.errorHttpResponseConverter.write(error, null, httpResponse); + } + + private static OAuth2AuthenticationException throwError(String errorCode, String parameterName) { + OAuth2Error error = new OAuth2Error(errorCode, "Token Revocation Request Parameter: " + parameterName, + "https://tools.ietf.org/html/rfc7009#section-2.1"); + throw new OAuth2AuthenticationException(error); + } + + private static class TokenRevocationAuthenticationConverter implements + Converter { + + @Override + public Authentication convert(HttpServletRequest request) { + MultiValueMap parameters = OAuth2EndpointUtils.getParameters(request); + + Authentication clientPrincipal = SecurityContextHolder.getContext().getAuthentication(); + + // token (REQUIRED) + String token = parameters.getFirst(TOKEN); + if (!StringUtils.hasText(token) || + parameters.get(TOKEN).size() != 1) { + throwError(OAuth2ErrorCodes.INVALID_REQUEST, TOKEN); + } + + // token_type_hint (OPTIONAL) + String tokenTypeHint = parameters.getFirst(TOKEN_TYPE_HINT); + + return new OAuth2TokenRevocationAuthenticationToken(token, clientPrincipal, tokenTypeHint); + } + } +} diff --git a/core/src/test/java/org/springframework/security/oauth2/server/authorization/DefaultOAuth2TokenRevocationServiceTests.java b/core/src/test/java/org/springframework/security/oauth2/server/authorization/DefaultOAuth2TokenRevocationServiceTests.java new file mode 100644 index 0000000..bd1fb77 --- /dev/null +++ b/core/src/test/java/org/springframework/security/oauth2/server/authorization/DefaultOAuth2TokenRevocationServiceTests.java @@ -0,0 +1,92 @@ +/* + * Copyright 2020 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.authorization; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.oauth2.server.authorization.client.TestRegisteredClients; + +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Tests for {@link DefaultOAuth2TokenRevocationService}. + * + * @author Vivek Babu + */ +public class DefaultOAuth2TokenRevocationServiceTests { + private static final RegisteredClient REGISTERED_CLIENT = TestRegisteredClients.registeredClient().build(); + private static final String PRINCIPAL_NAME = "principal"; + private static final String AUTHORIZATION_CODE = "code"; + private DefaultOAuth2TokenRevocationService revocationService; + private OAuth2AuthorizationService authorizationService; + + @Before + public void setup() { + this.authorizationService = mock(OAuth2AuthorizationService.class); + this.revocationService = new DefaultOAuth2TokenRevocationService(authorizationService); + } + + @Test + public void constructorWhenAuthorizationServiceNullThenThrowIllegalArgumentException() { + assertThatThrownBy(() -> new DefaultOAuth2TokenRevocationService(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("authorizationService cannot be null"); + } + + @Test + public void revokeWhenTokenNotFound() { + this.revocationService.revoke("token", TokenType.ACCESS_TOKEN); + verify(authorizationService, times(1)).findByTokenAndTokenType(eq("token"), + eq(TokenType.ACCESS_TOKEN)); + verify(authorizationService, times(0)).save(any()); + } + + @Test + public void revokeWhenTokenFound() { + OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, + "token", Instant.now().minusSeconds(60), Instant.now()); + OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(REGISTERED_CLIENT) + .principalName(PRINCIPAL_NAME) + .attribute(OAuth2AuthorizationAttributeNames.CODE, AUTHORIZATION_CODE) + .accessToken(accessToken) + .build(); + when(authorizationService.findByTokenAndTokenType(eq("token"), eq(TokenType.ACCESS_TOKEN))) + .thenReturn(authorization); + this.revocationService.revoke("token", TokenType.ACCESS_TOKEN); + + ArgumentCaptor authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class); + verify(this.authorizationService).save(authorizationCaptor.capture()); + final OAuth2Authorization savedAuthorization = authorizationCaptor.getValue(); + assertThat(savedAuthorization.getPrincipalName()).isEqualTo(authorization.getPrincipalName()); + assertThat((String) savedAuthorization.getAttribute(OAuth2AuthorizationAttributeNames.CODE)) + .isEqualTo(authorization.getAttribute(OAuth2AuthorizationAttributeNames.CODE)); + assertThat(savedAuthorization.getAccessToken()).isEqualTo(authorization.getAccessToken()); + assertThat(savedAuthorization.getRegisteredClientId()).isEqualTo(authorization.getRegisteredClientId()); + assertThat(savedAuthorization.isRevoked()).isTrue(); + } +} diff --git a/core/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenRevocationAuthenticationProviderTests.java b/core/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenRevocationAuthenticationProviderTests.java new file mode 100644 index 0000000..bec949f --- /dev/null +++ b/core/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenRevocationAuthenticationProviderTests.java @@ -0,0 +1,150 @@ +/* + * Copyright 2020 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.authorization.authentication; + +import org.junit.Before; +import org.junit.Test; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.server.authorization.OAuth2Authorization; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; +import org.springframework.security.oauth2.server.authorization.OAuth2TokenRevocationService; +import org.springframework.security.oauth2.server.authorization.TestOAuth2Authorizations; +import org.springframework.security.oauth2.server.authorization.TokenType; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.oauth2.server.authorization.client.TestRegisteredClients; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Tests for {@link OAuth2TokenRevocationAuthenticationProvider}. + * + * @author Vivek Babu + */ +public class OAuth2TokenRevocationAuthenticationProviderTests { + private RegisteredClient registeredClient; + private OAuth2AuthorizationService oAuth2AuthorizationService; + private OAuth2TokenRevocationService oAuth2TokenRevocationService; + private OAuth2TokenRevocationAuthenticationProvider authenticationProvider; + + @Before + public void setUp() { + this.registeredClient = TestRegisteredClients.registeredClient().build(); + this.oAuth2AuthorizationService = mock(OAuth2AuthorizationService.class); + this.oAuth2TokenRevocationService = mock(OAuth2TokenRevocationService.class); + this.authenticationProvider = new OAuth2TokenRevocationAuthenticationProvider(oAuth2AuthorizationService, + oAuth2TokenRevocationService); + } + + @Test + public void constructorWhenAuthorizationServiceNullThenThrowIllegalArgumentException() { + assertThatThrownBy(() -> new OAuth2TokenRevocationAuthenticationProvider(null, + oAuth2TokenRevocationService)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("authorizationService cannot be null"); + } + + @Test + public void constructorWhenRevocationServiceNullThenThrowIllegalArgumentException() { + assertThatThrownBy(() -> new OAuth2TokenRevocationAuthenticationProvider(oAuth2AuthorizationService, + null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("tokenRevocationService cannot be null"); + } + + @Test + public void supportsWhenTypeOAuth2TokenRevocationAuthenticationTokenThenReturnTrue() { + assertThat(this.authenticationProvider.supports(OAuth2TokenRevocationAuthenticationToken.class)).isTrue(); + } + + @Test + public void authenticateWhenClientPrincipalNotOAuth2ClientAuthenticationTokenThenThrowOAuth2AuthenticationException() { + TestingAuthenticationToken clientPrincipal = new TestingAuthenticationToken( + this.registeredClient.getClientId(), this.registeredClient.getClientSecret()); + OAuth2TokenRevocationAuthenticationToken authentication = new OAuth2TokenRevocationAuthenticationToken( + "token", clientPrincipal, "access_token"); + assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .isInstanceOf(OAuth2AuthenticationException.class) + .extracting(ex -> ((OAuth2AuthenticationException) ex).getError()) + .extracting("errorCode") + .isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT); + } + + @Test + public void authenticateWhenClientPrincipalNotAuthenticatedThenThrowOAuth2AuthenticationException() { + OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken( + this.registeredClient.getClientId(), this.registeredClient.getClientSecret()); + OAuth2TokenRevocationAuthenticationToken authentication = new OAuth2TokenRevocationAuthenticationToken( + "token", clientPrincipal, "access_token"); + assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .isInstanceOf(OAuth2AuthenticationException.class) + .extracting(ex -> ((OAuth2AuthenticationException) ex).getError()) + .extracting("errorCode") + .isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT); + } + + @Test + public void authenticateWhenInvalidTokenThenAuthenticate() { + OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(this.registeredClient); + OAuth2TokenRevocationAuthenticationToken authentication = new OAuth2TokenRevocationAuthenticationToken( + "token", clientPrincipal, "access_token"); + OAuth2TokenRevocationAuthenticationToken authenticationResult = + (OAuth2TokenRevocationAuthenticationToken) this.authenticationProvider.authenticate(authentication); + assertThat(authenticationResult.isAuthenticated()).isTrue(); + assertThat(authenticationResult.getPrincipal().toString()).isEqualTo(this.registeredClient.getClientId()); + assertThat(authenticationResult.getRegisteredClient()).isEqualTo(this.registeredClient); + } + + @Test + public void authenticateWhenAuthorizationIssuedToAnotherClientThenThrowOAuth2AuthenticationException() { + OAuth2Authorization authorization = TestOAuth2Authorizations.authorization().build(); + when(this.oAuth2AuthorizationService.findByTokenAndTokenType(eq("token"), eq(TokenType.ACCESS_TOKEN))) + .thenReturn(authorization); + OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken( + TestRegisteredClients.registeredClient2().build()); + OAuth2TokenRevocationAuthenticationToken authentication = new OAuth2TokenRevocationAuthenticationToken( + "token", clientPrincipal, "access_token"); + assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .isInstanceOf(OAuth2AuthenticationException.class) + .extracting(ex -> ((OAuth2AuthenticationException) ex).getError()) + .extracting("errorCode") + .isEqualTo(OAuth2ErrorCodes.INVALID_GRANT); + } + + @Test + public void authenticateWhenValidAccessTokenThenInvalidateTokenAndAuthenticate() { + OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(this.registeredClient); + OAuth2TokenRevocationAuthenticationToken authentication = new OAuth2TokenRevocationAuthenticationToken( + "token", clientPrincipal, "access_token"); + OAuth2Authorization mockAuthorization = mock(OAuth2Authorization.class); + when(oAuth2AuthorizationService.findByTokenAndTokenType(eq("token"), eq(TokenType.ACCESS_TOKEN))). + thenReturn(mockAuthorization); + when(mockAuthorization.getRegisteredClientId()).thenReturn(this.registeredClient.getClientId()); + OAuth2TokenRevocationAuthenticationToken authenticationResult = + (OAuth2TokenRevocationAuthenticationToken) this.authenticationProvider.authenticate(authentication); + verify(this.oAuth2TokenRevocationService).revoke(eq("token"), eq(TokenType.ACCESS_TOKEN)); + + assertThat(authenticationResult.isAuthenticated()).isTrue(); + assertThat(authenticationResult.getPrincipal().toString()).isEqualTo(this.registeredClient.getClientId()); + assertThat(authenticationResult.getRegisteredClient()).isEqualTo(this.registeredClient); + } +} diff --git a/core/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenRevocationAuthenticationTokenTests.java b/core/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenRevocationAuthenticationTokenTests.java new file mode 100644 index 0000000..f34cfc0 --- /dev/null +++ b/core/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenRevocationAuthenticationTokenTests.java @@ -0,0 +1,88 @@ +/* + * Copyright 2020 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.authorization.authentication; + +import org.junit.Test; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.oauth2.server.authorization.client.TestRegisteredClients; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Tests for {@link OAuth2TokenRevocationAuthenticationToken}. + * + * @author Vivek Babu + */ +public class OAuth2TokenRevocationAuthenticationTokenTests { + private OAuth2TokenRevocationAuthenticationToken clientPrincipal = new OAuth2TokenRevocationAuthenticationToken( + "Token", TestRegisteredClients.registeredClient().build(), null); + private RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + + @Test + public void constructorWhenTokenNullThenThrowIllegalArgumentException() { + assertThatThrownBy(() -> new OAuth2TokenRevocationAuthenticationToken(null, + this.clientPrincipal, "hint")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("token cannot be empty"); + } + + @Test + public void constructorWhenClientPrincipalNullThenThrowIllegalArgumentException() { + assertThatThrownBy(() -> new OAuth2TokenRevocationAuthenticationToken("token", + (Authentication) null, "hint")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("clientPrincipal cannot be null"); + } + + @Test + public void constructorWhenTokenNullRegisteredClientPresentThenThrowIllegalArgumentException() { + assertThatThrownBy(() -> new OAuth2TokenRevocationAuthenticationToken(null, registeredClient, "hint")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("token cannot be empty"); + } + + @Test + public void constructorWhenRegisteredClientNullThenThrowIllegalArgumentException() { + assertThatThrownBy(() -> new OAuth2TokenRevocationAuthenticationToken("token", + (RegisteredClient) null, "hint")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("registeredClient cannot be null"); + } + + @Test + public void constructorWhenTokenAndClientPrincipalProvidedThenCreated() { + OAuth2TokenRevocationAuthenticationToken authentication = new OAuth2TokenRevocationAuthenticationToken( + "token", this.clientPrincipal, "token_hint"); + assertThat(authentication.getPrincipal()).isEqualTo(this.clientPrincipal); + assertThat(authentication.getCredentials().toString()).isEmpty(); + assertThat(authentication.getToken()).isEqualTo("token"); + assertThat(authentication.getTokenTypeHint()).isEqualTo("token_hint"); + assertThat(authentication.isAuthenticated()).isFalse(); + } + + @Test + public void constructorWhenTokenAndRegisteredProvidedThenCreated() { + OAuth2TokenRevocationAuthenticationToken authentication = new OAuth2TokenRevocationAuthenticationToken( + "token", this.registeredClient, "token_hint"); + assertThat(authentication.getPrincipal()).isEqualTo(this.registeredClient.getClientId()); + assertThat(authentication.getCredentials().toString()).isEmpty(); + assertThat(authentication.getToken()).isEqualTo("token"); + assertThat(authentication.getTokenTypeHint()).isEqualTo("token_hint"); + assertThat(authentication.isAuthenticated()).isTrue(); + } +} diff --git a/core/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2TokenRevocationEndpointFilterTests.java b/core/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2TokenRevocationEndpointFilterTests.java new file mode 100644 index 0000000..101feb8 --- /dev/null +++ b/core/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2TokenRevocationEndpointFilterTests.java @@ -0,0 +1,196 @@ +/* + * Copyright 2020 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.authorization.web; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.http.HttpStatus; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.mock.http.client.MockClientHttpResponse; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.http.converter.OAuth2ErrorHttpMessageConverter; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2TokenRevocationAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.oauth2.server.authorization.client.TestRegisteredClients; + +import javax.servlet.FilterChain; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * Tests for {@link OAuth2TokenRevocationEndpointFilter}. + * + * @author Vivek Babu + */ +public class OAuth2TokenRevocationEndpointFilterTests { + private static final String TOKEN = "token"; + private static final String TOKEN_TYPE_HINT = "token_type_hint"; + private AuthenticationManager authenticationManager; + private OAuth2TokenRevocationEndpointFilter filter; + private final HttpMessageConverter errorHttpResponseConverter = + new OAuth2ErrorHttpMessageConverter(); + + @Before + public void setUp() { + this.authenticationManager = mock(AuthenticationManager.class); + this.filter = new OAuth2TokenRevocationEndpointFilter(this.authenticationManager); + } + + @After + public void cleanup() { + SecurityContextHolder.clearContext(); + } + + @Test + public void constructorWhenAuthenticationManagerNullThenThrowIllegalArgumentException() { + assertThatThrownBy(() -> new OAuth2TokenRevocationEndpointFilter(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("authenticationManager cannot be null"); + } + + @Test + public void constructorWhenTokenEndpointUriNullThenThrowIllegalArgumentException() { + assertThatThrownBy(() -> new OAuth2TokenRevocationEndpointFilter(this.authenticationManager, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("revocationEndpointUri cannot be empty"); + } + + @Test + public void doFilterWhenNotRevocationRequestThenNotProcessed() throws Exception { + String requestUri = "/path"; + MockHttpServletRequest request = new MockHttpServletRequest("POST", requestUri); + request.setServletPath(requestUri); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + + this.filter.doFilter(request, response, filterChain); + + verify(filterChain).doFilter(any(HttpServletRequest.class), any(HttpServletResponse.class)); + } + + @Test + public void doFilterWhenRevocationRequestGetThenNotProcessed() throws Exception { + String requestUri = OAuth2TokenEndpointFilter.DEFAULT_TOKEN_ENDPOINT_URI; + MockHttpServletRequest request = new MockHttpServletRequest("GET", requestUri); + request.setServletPath(requestUri); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + + this.filter.doFilter(request, response, filterChain); + + verify(filterChain).doFilter(any(HttpServletRequest.class), any(HttpServletResponse.class)); + } + + @Test + public void doFilterWhenRevocationRequestMissingTokenThenInvalidRequestError() throws Exception { + doFilterWhenRevocationRequestInvalidParameterThenError( + TOKEN, OAuth2ErrorCodes.INVALID_REQUEST, + request -> request.removeParameter(TOKEN)); + } + + @Test + public void doFilterWhenRevocationRequestMultipleTokenThenInvalidRequestError() throws Exception { + doFilterWhenRevocationRequestInvalidParameterThenError( + TOKEN, OAuth2ErrorCodes.INVALID_REQUEST, + request -> { + request.addParameter(TOKEN, "token-1"); + request.addParameter(TOKEN, "token-2"); + }); + } + + @Test + public void doFilterWhenTokenRequestValidThenAccessTokenResponse() throws Exception { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + Authentication clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient); + + Authentication tokenRevocationAuthenticationSuccess = mock(Authentication.class); + + when(this.authenticationManager.authenticate(any())).thenReturn(tokenRevocationAuthenticationSuccess); + + SecurityContext securityContext = SecurityContextHolder.createEmptyContext(); + securityContext.setAuthentication(clientPrincipal); + SecurityContextHolder.setContext(securityContext); + + MockHttpServletRequest request = createRevocationRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + + this.filter.doFilter(request, response, filterChain); + + verifyNoInteractions(filterChain); + + ArgumentCaptor tokenRevocationAuthenticationCaptor = + ArgumentCaptor.forClass(OAuth2TokenRevocationAuthenticationToken.class); + verify(this.authenticationManager).authenticate(tokenRevocationAuthenticationCaptor.capture()); + + assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value()); + } + + private void doFilterWhenRevocationRequestInvalidParameterThenError(String parameterName, String errorCode, + Consumer requestConsumer) throws Exception { + + MockHttpServletRequest request = createRevocationRequest(); + requestConsumer.accept(request); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + + this.filter.doFilter(request, response, filterChain); + + verifyNoInteractions(filterChain); + + assertThat(response.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST.value()); + OAuth2Error error = readError(response); + assertThat(error.getErrorCode()).isEqualTo(errorCode); + assertThat(error.getDescription()).isEqualTo("Token Revocation Request Parameter: " + parameterName); + } + + private OAuth2Error readError(MockHttpServletResponse response) throws Exception { + MockClientHttpResponse httpResponse = new MockClientHttpResponse( + response.getContentAsByteArray(), HttpStatus.valueOf(response.getStatus())); + return this.errorHttpResponseConverter.read(OAuth2Error.class, httpResponse); + } + + private static MockHttpServletRequest createRevocationRequest() { + + String requestUri = OAuth2TokenRevocationEndpointFilter.DEFAULT_TOKEN_REVOCATION_ENDPOINT_URI; + MockHttpServletRequest request = new MockHttpServletRequest("POST", requestUri); + request.setServletPath(requestUri); + + request.addParameter(TOKEN, "token"); + request.addParameter(TOKEN_TYPE_HINT, "access_token"); + + return request; + } +}