From ef859a3a51349a59dbd12ec83feeee3fb4048ad7 Mon Sep 17 00:00:00 2001 From: Steve Riesenberg <5248162+sjohnr@users.noreply.github.com> Date: Fri, 12 Apr 2024 15:56:21 -0500 Subject: [PATCH] Add tests for Token Exchange Issue gh-60 --- .../TestOAuth2Authorizations.java | 7 +- .../OAuth2TokenExchangeActorTests.java | 48 ++ ...enExchangeAuthenticationProviderTests.java | 702 ++++++++++++++++++ ...TokenExchangeAuthenticationTokenTests.java | 134 ++++ ...angeCompositeAuthenticationTokenTests.java | 66 ++ .../OAuth2TokenExchangeGrantTests.java | 368 +++++++++ ...DefaultOAuth2TokenClaimsConsumerTests.java | 128 ++++ .../web/OAuth2TokenEndpointFilterTests.java | 86 ++- ...nExchangeAuthenticationConverterTests.java | 336 +++++++++ 9 files changed, 1871 insertions(+), 4 deletions(-) create mode 100644 oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeActorTests.java create mode 100644 oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeAuthenticationProviderTests.java create mode 100644 oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeAuthenticationTokenTests.java create mode 100644 oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeCompositeAuthenticationTokenTests.java create mode 100644 oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2TokenExchangeGrantTests.java create mode 100644 oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/token/DefaultOAuth2TokenClaimsConsumerTests.java create mode 100644 oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2TokenExchangeAuthenticationConverterTests.java diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/TestOAuth2Authorizations.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/TestOAuth2Authorizations.java index fce02bd8..1bbacbc5 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/TestOAuth2Authorizations.java +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/TestOAuth2Authorizations.java @@ -30,6 +30,7 @@ import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequ import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; import org.springframework.security.oauth2.server.authorization.client.TestRegisteredClients; +import org.springframework.security.oauth2.server.authorization.settings.OAuth2TokenFormat; import org.springframework.util.CollectionUtils; /** @@ -92,15 +93,17 @@ public class TestOAuth2Authorizations { OAuth2RefreshToken refreshToken = new OAuth2RefreshToken( "refresh-token", Instant.now(), Instant.now().plus(1, ChronoUnit.HOURS)); builder - .token(accessToken, (metadata) -> metadata.putAll(tokenMetadata(accessTokenClaims))) + .token(accessToken, (metadata) -> metadata.putAll(tokenMetadata(registeredClient, accessTokenClaims))) .refreshToken(refreshToken); } return builder; } - private static Map tokenMetadata(Map tokenClaims) { + private static Map tokenMetadata(RegisteredClient registeredClient, Map tokenClaims) { Map tokenMetadata = new HashMap<>(); + OAuth2TokenFormat accessTokenFormat = registeredClient.getTokenSettings().getAccessTokenFormat(); + tokenMetadata.put(OAuth2TokenFormat.class.getName(), accessTokenFormat.getValue()); tokenMetadata.put(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME, false); if (CollectionUtils.isEmpty(tokenClaims)) { tokenClaims = defaultTokenClaims(); diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeActorTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeActorTests.java new file mode 100644 index 00000000..34784859 --- /dev/null +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeActorTests.java @@ -0,0 +1,48 @@ +/* + * Copyright 2020-2024 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 java.util.Map; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests for {@link OAuth2TokenExchangeActor}. + * + * @author Steve Riesenberg + */ +public class OAuth2TokenExchangeActorTests { + + @Test + public void constructorWhenClaimsNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> new OAuth2TokenExchangeActor(null)) + .withMessage("claims cannot be null"); + // @formatter:on + } + + @Test + public void constructorWhenRequiredParametersThenCreated() { + Map claims = Map.of("claim1", "value1"); + OAuth2TokenExchangeActor actor = new OAuth2TokenExchangeActor(claims); + assertThat(actor.getClaims()).isEqualTo(claims); + } + +} diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeAuthenticationProviderTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeAuthenticationProviderTests.java new file mode 100644 index 00000000..10cad005 --- /dev/null +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeAuthenticationProviderTests.java @@ -0,0 +1,702 @@ +/* + * Copyright 2020-2024 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 java.security.Principal; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Consumer; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.OAuth2Token; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.server.authorization.OAuth2Authorization; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; +import org.springframework.security.oauth2.server.authorization.OAuth2TokenType; +import org.springframework.security.oauth2.server.authorization.TestOAuth2Authorizations; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.oauth2.server.authorization.client.TestRegisteredClients; +import org.springframework.security.oauth2.server.authorization.context.AuthorizationServerContextHolder; +import org.springframework.security.oauth2.server.authorization.context.TestAuthorizationServerContext; +import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings; +import org.springframework.security.oauth2.server.authorization.settings.OAuth2TokenFormat; +import org.springframework.security.oauth2.server.authorization.settings.TokenSettings; +import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenClaimNames; +import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenContext; +import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenGenerator; + +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.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +/** + * Tests for {@link OAuth2TokenExchangeAuthenticationProvider}. + * + * @author Steve Riesenberg + */ +public class OAuth2TokenExchangeAuthenticationProviderTests { + private static final Set RESOURCES = Set.of("https://mydomain.com/resource1", "https://mydomain.com/resource2"); + private static final Set AUDIENCES = Set.of("audience1", "audience2"); + private static final String SUBJECT_TOKEN = "EfYu_0jEL"; + private static final String ACTOR_TOKEN = "JlNE_xR1f"; + private static final String ACCESS_TOKEN_TYPE_VALUE = "urn:ietf:params:oauth:token-type:access_token"; + private static final String JWT_TOKEN_TYPE_VALUE = "urn:ietf:params:oauth:token-type:jwt"; + + private OAuth2AuthorizationService authorizationService; + private OAuth2TokenGenerator tokenGenerator; + private OAuth2TokenExchangeAuthenticationProvider authenticationProvider; + + @BeforeEach + @SuppressWarnings("unchecked") + public void setUp() { + this.authorizationService = mock(OAuth2AuthorizationService.class); + this.tokenGenerator = mock(OAuth2TokenGenerator.class); + this.authenticationProvider = new OAuth2TokenExchangeAuthenticationProvider(this.authorizationService, + this.tokenGenerator); + mockAuthorizationServerContext(); + } + + @AfterEach + public void tearDown() { + AuthorizationServerContextHolder.resetContext(); + } + + @Test + public void constructorWhenAuthorizationServiceNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> new OAuth2TokenExchangeAuthenticationProvider(null, this.tokenGenerator)) + .withMessage("authorizationService cannot be null"); + // @formatter:on + } + + @Test + public void constructorWhenTokenGeneratorNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> new OAuth2TokenExchangeAuthenticationProvider(this.authorizationService, null)) + .withMessage("tokenGenerator cannot be null"); + // @formatter:on + } + + @Test + public void supportsWhenTypeOAuth2TokenExchangeAuthenticationTokenThenReturnTrue() { + assertThat(this.authenticationProvider.supports(OAuth2TokenExchangeAuthenticationToken.class)).isTrue(); + } + + @Test + public void authenticateWhenClientNotAuthenticatedThenThrowOAuth2AuthenticationException() { + OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken("client-1", + ClientAuthenticationMethod.CLIENT_SECRET_BASIC, null, null); + Authentication authentication = new OAuth2TokenExchangeAuthenticationToken(JWT_TOKEN_TYPE_VALUE, SUBJECT_TOKEN, + ACCESS_TOKEN_TYPE_VALUE, clientPrincipal, null, null, RESOURCES, AUDIENCES, null, null); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT); + // @formatter:on + } + + @Test + public void authenticateWhenInvalidGrantTypeThenThrowOAuth2AuthenticationException() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + OAuth2TokenExchangeAuthenticationToken authentication = createDelegationRequest(registeredClient); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.UNAUTHORIZED_CLIENT); + // @formatter:on + } + + @Test + public void authenticateWhenInvalidRequestedTokenTypeThenThrowOAuth2AuthenticationException() { + // @formatter:off + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .authorizationGrantType(AuthorizationGrantType.TOKEN_EXCHANGE) + .tokenSettings(TokenSettings.builder().accessTokenFormat(OAuth2TokenFormat.REFERENCE).build()) + .build(); + // @formatter:on + OAuth2TokenExchangeAuthenticationToken authentication = createDelegationRequest(registeredClient); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST); + // @formatter:on + } + + @Test + public void authenticateWhenSubjectTokenNotFoundThenThrowOAuth2AuthenticationException() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .authorizationGrantType(AuthorizationGrantType.TOKEN_EXCHANGE).build(); + OAuth2TokenExchangeAuthenticationToken authentication = createDelegationRequest(registeredClient); + when(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).thenReturn(null); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.INVALID_GRANT); + // @formatter:on + + verify(this.authorizationService).findByToken(SUBJECT_TOKEN, OAuth2TokenType.ACCESS_TOKEN); + verifyNoMoreInteractions(this.authorizationService); + verifyNoInteractions(this.tokenGenerator); + } + + @Test + public void authenticateWhenSubjectTokenNotActiveThenThrowOAuth2AuthenticationException() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .authorizationGrantType(AuthorizationGrantType.TOKEN_EXCHANGE).build(); + OAuth2TokenExchangeAuthenticationToken authentication = createDelegationRequest(registeredClient); + OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient) + .token(createExpiredAccessToken(SUBJECT_TOKEN)).build(); + when(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).thenReturn(authorization); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.INVALID_GRANT); + // @formatter:on + + verify(this.authorizationService).findByToken(SUBJECT_TOKEN, OAuth2TokenType.ACCESS_TOKEN); + verifyNoMoreInteractions(this.authorizationService); + verifyNoInteractions(this.tokenGenerator); + } + + @Test + public void authenticateWhenSubjectTokenTypeJwtAndSubjectTokenFormatReferenceThenThrowOAuth2AuthenticationException() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .authorizationGrantType(AuthorizationGrantType.TOKEN_EXCHANGE).build(); + OAuth2TokenExchangeAuthenticationToken authentication = createJwtRequest(registeredClient); + OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient) + .token(createAccessToken(SUBJECT_TOKEN), withTokenFormat(OAuth2TokenFormat.REFERENCE)).build(); + when(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).thenReturn(authorization); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST); + // @formatter:on + + verify(this.authorizationService).findByToken(SUBJECT_TOKEN, OAuth2TokenType.ACCESS_TOKEN); + verifyNoMoreInteractions(this.authorizationService); + verifyNoInteractions(this.tokenGenerator); + } + + @Test + public void authenticateWhenSubjectPrincipalNullThenThrowOAuth2AuthenticationException() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .authorizationGrantType(AuthorizationGrantType.TOKEN_EXCHANGE).build(); + OAuth2TokenExchangeAuthenticationToken authentication = createDelegationRequest(registeredClient); + // @formatter:off + OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient) + .token(createAccessToken(SUBJECT_TOKEN)) + .attributes((attributes) -> attributes.remove(Principal.class.getName())) + .build(); + // @formatter:on + when(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).thenReturn(authorization); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.INVALID_GRANT); + // @formatter:on + + verify(this.authorizationService).findByToken(SUBJECT_TOKEN, OAuth2TokenType.ACCESS_TOKEN); + verifyNoMoreInteractions(this.authorizationService); + verifyNoInteractions(this.tokenGenerator); + } + + @Test + public void authenticateWhenActorTokenNotFoundThenThrowOAuth2AuthenticationException() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .authorizationGrantType(AuthorizationGrantType.TOKEN_EXCHANGE).build(); + OAuth2TokenExchangeAuthenticationToken authentication = createDelegationRequest(registeredClient); + OAuth2Authorization subjectAuthorization = TestOAuth2Authorizations.authorization(registeredClient) + .token(createAccessToken(SUBJECT_TOKEN)).build(); + when(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).thenReturn( + subjectAuthorization, (OAuth2Authorization) null); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.INVALID_GRANT); + // @formatter:on + + verify(this.authorizationService).findByToken(SUBJECT_TOKEN, OAuth2TokenType.ACCESS_TOKEN); + verify(this.authorizationService).findByToken(ACTOR_TOKEN, OAuth2TokenType.ACCESS_TOKEN); + verifyNoMoreInteractions(this.authorizationService); + verifyNoInteractions(this.tokenGenerator); + } + + @Test + public void authenticateWhenActorTokenNotActiveThenThrowOAuth2AuthenticationException() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .authorizationGrantType(AuthorizationGrantType.TOKEN_EXCHANGE).build(); + OAuth2TokenExchangeAuthenticationToken authentication = createDelegationRequest(registeredClient); + OAuth2Authorization subjectAuthorization = TestOAuth2Authorizations.authorization(registeredClient) + .token(createAccessToken(SUBJECT_TOKEN)).build(); + OAuth2Authorization actorAuthorization = TestOAuth2Authorizations.authorization(registeredClient) + .token(createExpiredAccessToken(ACTOR_TOKEN)).build(); + when(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).thenReturn( + subjectAuthorization, actorAuthorization); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.INVALID_GRANT); + // @formatter:on + + verify(this.authorizationService).findByToken(SUBJECT_TOKEN, OAuth2TokenType.ACCESS_TOKEN); + verify(this.authorizationService).findByToken(ACTOR_TOKEN, OAuth2TokenType.ACCESS_TOKEN); + verifyNoMoreInteractions(this.authorizationService); + verifyNoInteractions(this.tokenGenerator); + } + + @Test + public void authenticateWhenActorTokenTypeJwtAndActorTokenFormatReferenceThenThrowOAuth2AuthenticationException() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .authorizationGrantType(AuthorizationGrantType.TOKEN_EXCHANGE).build(); + OAuth2TokenExchangeAuthenticationToken authentication = createJwtRequest(registeredClient); + OAuth2Authorization subjectAuthorization = TestOAuth2Authorizations.authorization(registeredClient) + .token(createAccessToken(SUBJECT_TOKEN), withTokenFormat(OAuth2TokenFormat.SELF_CONTAINED)).build(); + OAuth2Authorization actorAuthorization = TestOAuth2Authorizations.authorization(registeredClient) + .token(createAccessToken(ACTOR_TOKEN), withTokenFormat(OAuth2TokenFormat.REFERENCE)).build(); + when(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).thenReturn( + subjectAuthorization, actorAuthorization); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST); + // @formatter:on + + verify(this.authorizationService).findByToken(SUBJECT_TOKEN, OAuth2TokenType.ACCESS_TOKEN); + verify(this.authorizationService).findByToken(ACTOR_TOKEN, OAuth2TokenType.ACCESS_TOKEN); + verifyNoMoreInteractions(this.authorizationService); + verifyNoInteractions(this.tokenGenerator); + } + + @Test + public void authenticateWhenMayActAndActorIssClaimNotAuthorizedThenThrowOAuth2AuthenticationException() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .authorizationGrantType(AuthorizationGrantType.TOKEN_EXCHANGE).build(); + OAuth2TokenExchangeAuthenticationToken authentication = createDelegationRequest(registeredClient); + Map authorizedActorClaims = Map.of(OAuth2TokenClaimNames.ISS, "issuer", + OAuth2TokenClaimNames.SUB, "actor"); + // @formatter:off + OAuth2Authorization subjectAuthorization = TestOAuth2Authorizations.authorization(registeredClient) + .token(createAccessToken(SUBJECT_TOKEN), withClaims(Map.of("may_act", authorizedActorClaims))) + .build(); + // @formatter:on + Map actorTokenClaims = Map.of(OAuth2TokenClaimNames.ISS, "invalid-issuer", + OAuth2TokenClaimNames.SUB, "actor"); + OAuth2Authorization actorAuthorization = TestOAuth2Authorizations.authorization(registeredClient) + .token(createAccessToken(ACTOR_TOKEN), withClaims(actorTokenClaims)).build(); + when(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).thenReturn( + subjectAuthorization, actorAuthorization); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.INVALID_GRANT); + // @formatter:on + + verify(this.authorizationService).findByToken(SUBJECT_TOKEN, OAuth2TokenType.ACCESS_TOKEN); + verify(this.authorizationService).findByToken(ACTOR_TOKEN, OAuth2TokenType.ACCESS_TOKEN); + verifyNoMoreInteractions(this.authorizationService); + verifyNoInteractions(this.tokenGenerator); + } + + @Test + public void authenticateWhenMayActAndActorSubClaimNotAuthorizedThenThrowOAuth2AuthenticationException() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .authorizationGrantType(AuthorizationGrantType.TOKEN_EXCHANGE).build(); + OAuth2TokenExchangeAuthenticationToken authentication = createDelegationRequest(registeredClient); + Map authorizedActorClaims = Map.of(OAuth2TokenClaimNames.ISS, "issuer", + OAuth2TokenClaimNames.SUB, "actor"); + // @formatter:off + OAuth2Authorization subjectAuthorization = TestOAuth2Authorizations.authorization(registeredClient) + .token(createAccessToken(SUBJECT_TOKEN), withClaims(Map.of("may_act", authorizedActorClaims))) + .build(); + // @formatter:on + Map actorTokenClaims = Map.of(OAuth2TokenClaimNames.ISS, "issuer", OAuth2TokenClaimNames.SUB, + "invalid-actor"); + OAuth2Authorization actorAuthorization = TestOAuth2Authorizations.authorization(registeredClient) + .token(createAccessToken(ACTOR_TOKEN), withClaims(actorTokenClaims)).build(); + when(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).thenReturn( + subjectAuthorization, actorAuthorization); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.INVALID_GRANT); + // @formatter:on + + verify(this.authorizationService).findByToken(SUBJECT_TOKEN, OAuth2TokenType.ACCESS_TOKEN); + verify(this.authorizationService).findByToken(ACTOR_TOKEN, OAuth2TokenType.ACCESS_TOKEN); + verifyNoMoreInteractions(this.authorizationService); + verifyNoInteractions(this.tokenGenerator); + } + + @Test + public void authenticateWhenMayActAndImpersonationThenThrowOAuth2AuthenticationException() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .authorizationGrantType(AuthorizationGrantType.TOKEN_EXCHANGE).build(); + OAuth2TokenExchangeAuthenticationToken authentication = createImpersonationRequest(registeredClient); + Map authorizedActorClaims = Map.of(OAuth2TokenClaimNames.ISS, "issuer", + OAuth2TokenClaimNames.SUB, "actor"); + // @formatter:off + OAuth2Authorization subjectAuthorization = TestOAuth2Authorizations.authorization(registeredClient) + .token(createAccessToken(SUBJECT_TOKEN), withClaims(Map.of("may_act", authorizedActorClaims))) + .build(); + // @formatter:on + when(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).thenReturn( + subjectAuthorization); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.INVALID_GRANT); + // @formatter:on + + verify(this.authorizationService).findByToken(SUBJECT_TOKEN, OAuth2TokenType.ACCESS_TOKEN); + verifyNoMoreInteractions(this.authorizationService); + verifyNoInteractions(this.tokenGenerator); + } + + @Test + public void authenticateWhenInvalidScopeInRequestThenThrowOAuth2AuthenticationException() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .authorizationGrantType(AuthorizationGrantType.TOKEN_EXCHANGE).build(); + OAuth2TokenExchangeAuthenticationToken authentication = createDelegationRequest(registeredClient, + Set.of("invalid")); + OAuth2Authorization subjectAuthorization = TestOAuth2Authorizations.authorization(registeredClient) + .token(createAccessToken(SUBJECT_TOKEN)).build(); + OAuth2Authorization actorAuthorization = TestOAuth2Authorizations.authorization(registeredClient) + .token(createAccessToken(ACTOR_TOKEN)).build(); + when(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).thenReturn( + subjectAuthorization, actorAuthorization); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.INVALID_SCOPE); + // @formatter:on + + verify(this.authorizationService).findByToken(SUBJECT_TOKEN, OAuth2TokenType.ACCESS_TOKEN); + verify(this.authorizationService).findByToken(ACTOR_TOKEN, OAuth2TokenType.ACCESS_TOKEN); + verifyNoMoreInteractions(this.authorizationService); + verifyNoInteractions(this.tokenGenerator); + } + + @Test + public void authenticateWhenInvalidScopeInSubjectAuthorizationThenThrowOAuth2AuthenticationException() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .authorizationGrantType(AuthorizationGrantType.TOKEN_EXCHANGE).build(); + OAuth2TokenExchangeAuthenticationToken authentication = createDelegationRequest(registeredClient, Set.of()); + OAuth2Authorization subjectAuthorization = TestOAuth2Authorizations.authorization(registeredClient) + .token(createAccessToken(SUBJECT_TOKEN)).authorizedScopes(Set.of("invalid")).build(); + OAuth2Authorization actorAuthorization = TestOAuth2Authorizations.authorization(registeredClient) + .token(createAccessToken(ACTOR_TOKEN)).build(); + when(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).thenReturn( + subjectAuthorization, actorAuthorization); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.INVALID_SCOPE); + // @formatter:on + + verify(this.authorizationService).findByToken(SUBJECT_TOKEN, OAuth2TokenType.ACCESS_TOKEN); + verify(this.authorizationService).findByToken(ACTOR_TOKEN, OAuth2TokenType.ACCESS_TOKEN); + verifyNoMoreInteractions(this.authorizationService); + verifyNoInteractions(this.tokenGenerator); + } + + @Test + public void authenticateWhenNoActorTokenAndValidTokenExchangeThenReturnAccessTokenForImpersonation() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .authorizationGrantType(AuthorizationGrantType.TOKEN_EXCHANGE).build(); + OAuth2TokenExchangeAuthenticationToken authentication = createImpersonationRequest(registeredClient); + TestingAuthenticationToken userPrincipal = new TestingAuthenticationToken("user", null, "ROLE_USER"); + // @formatter:off + OAuth2Authorization subjectAuthorization = TestOAuth2Authorizations.authorization(registeredClient) + .token(createAccessToken(SUBJECT_TOKEN)) + .attribute(Principal.class.getName(), userPrincipal) + .build(); + // @formatter:on + when(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).thenReturn( + subjectAuthorization); + OAuth2AccessToken accessToken = createAccessToken("token-value"); + when(this.tokenGenerator.generate(any(OAuth2TokenContext.class))).thenReturn(accessToken); + OAuth2AccessTokenAuthenticationToken authenticationResult = + (OAuth2AccessTokenAuthenticationToken) this.authenticationProvider.authenticate(authentication); + assertThat(authenticationResult.getRegisteredClient()).isEqualTo(registeredClient); + assertThat(authenticationResult.getPrincipal()).isEqualTo(authentication.getPrincipal()); + assertThat(authenticationResult.getAccessToken()).isEqualTo(accessToken); + assertThat(authenticationResult.getRefreshToken()).isNull(); + assertThat(authenticationResult.getAdditionalParameters()).hasSize(1); + assertThat(authenticationResult.getAdditionalParameters().get(OAuth2ParameterNames.ISSUED_TOKEN_TYPE)) + .isEqualTo(JWT_TOKEN_TYPE_VALUE); + + ArgumentCaptor authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class); + ArgumentCaptor tokenContextCaptor = ArgumentCaptor.forClass(OAuth2TokenContext.class); + verify(this.authorizationService).findByToken(SUBJECT_TOKEN, OAuth2TokenType.ACCESS_TOKEN); + verify(this.tokenGenerator).generate(tokenContextCaptor.capture()); + verify(this.authorizationService).save(authorizationCaptor.capture()); + verifyNoMoreInteractions(this.authorizationService, this.tokenGenerator); + + OAuth2TokenContext tokenContext = tokenContextCaptor.getValue(); + assertThat(tokenContext.getRegisteredClient()).isEqualTo(registeredClient); + assertThat(tokenContext.getAuthorization()).isEqualTo(subjectAuthorization); + assertThat(tokenContext.getPrincipal()).isSameAs(userPrincipal); + assertThat(tokenContext.getAuthorizationServerContext()).isNotNull(); + assertThat(tokenContext.getAuthorizedScopes()).isEqualTo(authentication.getScopes()); + assertThat(tokenContext.getTokenType()).isEqualTo(OAuth2TokenType.ACCESS_TOKEN); + assertThat(tokenContext.getAuthorizationGrant()).isEqualTo(authentication); + assertThat(tokenContext.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.TOKEN_EXCHANGE); + + OAuth2Authorization authorization = authorizationCaptor.getValue(); + assertThat(authorization.getPrincipalName()).isEqualTo(subjectAuthorization.getPrincipalName()); + assertThat(authorization.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.TOKEN_EXCHANGE); + assertThat(authorization.getAuthorizedScopes()).isEqualTo(authentication.getScopes()); + assertThat(authorization.getAttribute(Principal.class.getName())).isSameAs(userPrincipal); + assertThat(authorization.getAccessToken().getToken()).isEqualTo(accessToken); + assertThat(authorization.getRefreshToken()).isNull(); + } + + @Test + public void authenticateWhenNoActorTokenAndPreviousActorThenReturnAccessTokenForImpersonation() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .authorizationGrantType(AuthorizationGrantType.TOKEN_EXCHANGE).build(); + OAuth2TokenExchangeAuthenticationToken authentication = createImpersonationRequest(registeredClient); + TestingAuthenticationToken userPrincipal = new TestingAuthenticationToken("user", null, "ROLE_USER"); + OAuth2TokenExchangeActor previousActor = new OAuth2TokenExchangeActor(Map.of(OAuth2TokenClaimNames.ISS, "issuer1", + OAuth2TokenClaimNames.SUB, "actor")); + OAuth2TokenExchangeCompositeAuthenticationToken subjectPrincipal = + new OAuth2TokenExchangeCompositeAuthenticationToken(userPrincipal, List.of(previousActor)); + // @formatter:off + OAuth2Authorization subjectAuthorization = TestOAuth2Authorizations.authorization(registeredClient) + .token(createAccessToken(SUBJECT_TOKEN)) + .attribute(Principal.class.getName(), subjectPrincipal) + .build(); + // @formatter:on + when(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).thenReturn( + subjectAuthorization); + OAuth2AccessToken accessToken = createAccessToken("token-value"); + when(this.tokenGenerator.generate(any(OAuth2TokenContext.class))).thenReturn(accessToken); + OAuth2AccessTokenAuthenticationToken authenticationResult = + (OAuth2AccessTokenAuthenticationToken) this.authenticationProvider.authenticate(authentication); + assertThat(authenticationResult.getRegisteredClient()).isEqualTo(registeredClient); + assertThat(authenticationResult.getPrincipal()).isEqualTo(authentication.getPrincipal()); + assertThat(authenticationResult.getAccessToken()).isEqualTo(accessToken); + assertThat(authenticationResult.getRefreshToken()).isNull(); + assertThat(authenticationResult.getAdditionalParameters()).hasSize(1); + assertThat(authenticationResult.getAdditionalParameters().get(OAuth2ParameterNames.ISSUED_TOKEN_TYPE)) + .isEqualTo(JWT_TOKEN_TYPE_VALUE); + + ArgumentCaptor authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class); + ArgumentCaptor tokenContextCaptor = ArgumentCaptor.forClass(OAuth2TokenContext.class); + verify(this.authorizationService).findByToken(SUBJECT_TOKEN, OAuth2TokenType.ACCESS_TOKEN); + verify(this.tokenGenerator).generate(tokenContextCaptor.capture()); + verify(this.authorizationService).save(authorizationCaptor.capture()); + verifyNoMoreInteractions(this.authorizationService, this.tokenGenerator); + + OAuth2TokenContext tokenContext = tokenContextCaptor.getValue(); + assertThat(tokenContext.getRegisteredClient()).isEqualTo(registeredClient); + assertThat(tokenContext.getAuthorization()).isEqualTo(subjectAuthorization); + assertThat(tokenContext.getPrincipal()).isSameAs(userPrincipal); + assertThat(tokenContext.getAuthorizationServerContext()).isNotNull(); + assertThat(tokenContext.getAuthorizedScopes()).isEqualTo(authentication.getScopes()); + assertThat(tokenContext.getTokenType()).isEqualTo(OAuth2TokenType.ACCESS_TOKEN); + assertThat(tokenContext.getAuthorizationGrant()).isEqualTo(authentication); + assertThat(tokenContext.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.TOKEN_EXCHANGE); + + OAuth2Authorization authorization = authorizationCaptor.getValue(); + assertThat(authorization.getPrincipalName()).isEqualTo(subjectAuthorization.getPrincipalName()); + assertThat(authorization.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.TOKEN_EXCHANGE); + assertThat(authorization.getAuthorizedScopes()).isEqualTo(authentication.getScopes()); + assertThat(authorization.getAttribute(Principal.class.getName())).isSameAs(userPrincipal); + assertThat(authorization.getAccessToken().getToken()).isEqualTo(accessToken); + assertThat(authorization.getRefreshToken()).isNull(); + } + + @Test + public void authenticateWhenActorTokenAndValidTokenExchangeThenReturnAccessTokenForDelegation() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .authorizationGrantType(AuthorizationGrantType.TOKEN_EXCHANGE).build(); + OAuth2TokenExchangeAuthenticationToken authentication = createDelegationRequest(registeredClient); + TestingAuthenticationToken userPrincipal = new TestingAuthenticationToken("user", null, "ROLE_USER"); + OAuth2TokenExchangeActor actor1 = new OAuth2TokenExchangeActor(Map.of(OAuth2TokenClaimNames.ISS, "issuer1", + OAuth2TokenClaimNames.SUB, "actor1")); + OAuth2TokenExchangeActor actor2 = new OAuth2TokenExchangeActor(Map.of(OAuth2TokenClaimNames.ISS, "issuer2", + OAuth2TokenClaimNames.SUB, "actor2")); + OAuth2TokenExchangeCompositeAuthenticationToken subjectPrincipal = + new OAuth2TokenExchangeCompositeAuthenticationToken(userPrincipal, List.of(actor1)); + // @formatter:off + OAuth2Authorization subjectAuthorization = TestOAuth2Authorizations.authorization(registeredClient) + .token(createAccessToken(SUBJECT_TOKEN), withClaims(Map.of("may_act", actor2.getClaims()))) + .attribute(Principal.class.getName(), subjectPrincipal) + .build(); + OAuth2Authorization actorAuthorization = TestOAuth2Authorizations.authorization(registeredClient) + .principalName(actor2.getSubject()) + .token(createAccessToken(ACTOR_TOKEN), withClaims(actor2.getClaims())) + .build(); + // @formatter:on + when(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).thenReturn( + subjectAuthorization, actorAuthorization); + OAuth2AccessToken accessToken = createAccessToken("token-value"); + when(this.tokenGenerator.generate(any(OAuth2TokenContext.class))).thenReturn(accessToken); + OAuth2AccessTokenAuthenticationToken authenticationResult = + (OAuth2AccessTokenAuthenticationToken) this.authenticationProvider.authenticate(authentication); + assertThat(authenticationResult.getRegisteredClient()).isEqualTo(registeredClient); + assertThat(authenticationResult.getPrincipal()).isEqualTo(authentication.getPrincipal()); + assertThat(authenticationResult.getAccessToken()).isEqualTo(accessToken); + assertThat(authenticationResult.getRefreshToken()).isNull(); + assertThat(authenticationResult.getAdditionalParameters()).hasSize(1); + assertThat(authenticationResult.getAdditionalParameters().get(OAuth2ParameterNames.ISSUED_TOKEN_TYPE)) + .isEqualTo(JWT_TOKEN_TYPE_VALUE); + + ArgumentCaptor authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class); + ArgumentCaptor tokenContextCaptor = ArgumentCaptor.forClass(OAuth2TokenContext.class); + verify(this.authorizationService).findByToken(SUBJECT_TOKEN, OAuth2TokenType.ACCESS_TOKEN); + verify(this.authorizationService).findByToken(ACTOR_TOKEN, OAuth2TokenType.ACCESS_TOKEN); + verify(this.tokenGenerator).generate(tokenContextCaptor.capture()); + verify(this.authorizationService).save(authorizationCaptor.capture()); + verifyNoMoreInteractions(this.authorizationService, this.tokenGenerator); + + OAuth2TokenContext tokenContext = tokenContextCaptor.getValue(); + assertThat(tokenContext.getRegisteredClient()).isEqualTo(registeredClient); + assertThat(tokenContext.getAuthorization()).isEqualTo(subjectAuthorization); + assertThat(tokenContext.getAuthorizationServerContext()).isNotNull(); + assertThat(tokenContext.getAuthorizedScopes()).isEqualTo(authentication.getScopes()); + assertThat(tokenContext.getTokenType()).isEqualTo(OAuth2TokenType.ACCESS_TOKEN); + assertThat(tokenContext.getAuthorizationGrant()).isEqualTo(authentication); + assertThat(tokenContext.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.TOKEN_EXCHANGE); + + OAuth2TokenExchangeCompositeAuthenticationToken tokenContextPrincipal = tokenContext.getPrincipal(); + assertThat(tokenContextPrincipal.getSubject()).isSameAs(subjectPrincipal.getSubject()); + assertThat(tokenContextPrincipal.getActors()).containsExactly(actor2, actor1); + + OAuth2Authorization authorization = authorizationCaptor.getValue(); + assertThat(authorization.getPrincipalName()).isEqualTo(subjectAuthorization.getPrincipalName()); + assertThat(authorization.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.TOKEN_EXCHANGE); + assertThat(authorization.getAuthorizedScopes()).isEqualTo(authentication.getScopes()); + assertThat(authorization.getAccessToken().getToken()).isEqualTo(accessToken); + assertThat(authorization.getRefreshToken()).isNull(); + + OAuth2TokenExchangeCompositeAuthenticationToken authorizationPrincipal = + authorization.getAttribute(Principal.class.getName()); + assertThat(authorizationPrincipal).isNotNull(); + assertThat(authorizationPrincipal.getSubject()).isSameAs(subjectPrincipal.getSubject()); + assertThat(authorizationPrincipal.getActors()).containsExactly(actor2, actor1); + } + + private static void mockAuthorizationServerContext() { + AuthorizationServerSettings authorizationServerSettings = AuthorizationServerSettings.builder().build(); + TestAuthorizationServerContext authorizationServerContext = new TestAuthorizationServerContext( + authorizationServerSettings, () -> "https://provider.com"); + AuthorizationServerContextHolder.setContext(authorizationServerContext); + } + + private static OAuth2TokenExchangeAuthenticationToken createDelegationRequest(RegisteredClient registeredClient) { + return createDelegationRequest(registeredClient, registeredClient.getScopes()); + } + + private static OAuth2TokenExchangeAuthenticationToken createDelegationRequest(RegisteredClient registeredClient, + Set requestedScopes) { + OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient, + ClientAuthenticationMethod.CLIENT_SECRET_BASIC, null); + return new OAuth2TokenExchangeAuthenticationToken(JWT_TOKEN_TYPE_VALUE, SUBJECT_TOKEN, ACCESS_TOKEN_TYPE_VALUE, + clientPrincipal, ACTOR_TOKEN, ACCESS_TOKEN_TYPE_VALUE, RESOURCES, AUDIENCES, requestedScopes, null); + } + + private static OAuth2TokenExchangeAuthenticationToken createImpersonationRequest(RegisteredClient registeredClient) { + OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient, + ClientAuthenticationMethod.CLIENT_SECRET_BASIC, null); + return new OAuth2TokenExchangeAuthenticationToken(JWT_TOKEN_TYPE_VALUE, SUBJECT_TOKEN, ACCESS_TOKEN_TYPE_VALUE, + clientPrincipal, null, null, RESOURCES, AUDIENCES, registeredClient.getScopes(), null); + } + + private static OAuth2TokenExchangeAuthenticationToken createJwtRequest(RegisteredClient registeredClient) { + OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient, + ClientAuthenticationMethod.CLIENT_SECRET_BASIC, null); + return new OAuth2TokenExchangeAuthenticationToken(JWT_TOKEN_TYPE_VALUE, SUBJECT_TOKEN, JWT_TOKEN_TYPE_VALUE, + clientPrincipal, ACTOR_TOKEN, JWT_TOKEN_TYPE_VALUE, RESOURCES, AUDIENCES, registeredClient.getScopes(), + null); + } + + private static OAuth2AccessToken createAccessToken(String tokenValue) { + Instant issuedAt = Instant.now(); + Instant expiresAt = issuedAt.plus(30, ChronoUnit.MINUTES); + return new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, tokenValue, issuedAt, expiresAt); + } + + private static OAuth2AccessToken createExpiredAccessToken(String tokenValue) { + Instant issuedAt = Instant.now().minus(45, ChronoUnit.MINUTES); + Instant expiresAt = issuedAt.plus(30, ChronoUnit.MINUTES); + return new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, tokenValue, issuedAt, expiresAt); + } + + private static Consumer> withClaims(Map claims) { + return (metadata) -> metadata.put(OAuth2Authorization.Token.CLAIMS_METADATA_NAME, claims); + } + + private static Consumer> withTokenFormat(OAuth2TokenFormat tokenFormat) { + return (metadata) -> metadata.put(OAuth2TokenFormat.class.getName(), tokenFormat.getValue()); + } + +} diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeAuthenticationTokenTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeAuthenticationTokenTests.java new file mode 100644 index 00000000..3795d7b9 --- /dev/null +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeAuthenticationTokenTests.java @@ -0,0 +1,134 @@ +/* + * Copyright 2020-2024 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 java.util.Collections; +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +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 OAuth2TokenExchangeAuthenticationToken}. + * + * @author Steve Riesenberg + */ +public class OAuth2TokenExchangeAuthenticationTokenTests { + private static final Set RESOURCES = Set.of("https://mydomain.com/resource1", "https://mydomain.com/resource2"); + private static final Set AUDIENCES = Set.of("audience1", "audience2"); + private static final String SUBJECT_TOKEN = "EfYu_0jEL"; + private static final String ACTOR_TOKEN = "JlNE_xR1f"; + private static final String ACCESS_TOKEN_TYPE_VALUE = "urn:ietf:params:oauth:token-type:access_token"; + private static final String JWT_TOKEN_TYPE_VALUE = "urn:ietf:params:oauth:token-type:jwt"; + + private RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + private OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken( + this.registeredClient, ClientAuthenticationMethod.CLIENT_SECRET_BASIC, this.registeredClient.getClientSecret()); + private Set scopes = Collections.singleton("scope1"); + private Map additionalParameters = Collections.singletonMap("param1", "value1"); + + @Test + public void constructorWhenClientPrincipalNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatThrownBy(() -> new OAuth2TokenExchangeAuthenticationToken(null, null, null, null, null, null, null, null, null, this.additionalParameters)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("clientPrincipal cannot be null"); + // @formatter:on + } + + @Test + public void constructorWhenRequestedTokenTypeNullOrEmptyThenThrowIllegalArgumentException() { + // @formatter:off + assertThatThrownBy(() -> new OAuth2TokenExchangeAuthenticationToken(null, null, null, this.clientPrincipal, null, null, null, null, null, this.additionalParameters)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("requestedTokenType cannot be empty"); + assertThatThrownBy(() -> new OAuth2TokenExchangeAuthenticationToken("", null, null, this.clientPrincipal, null, null, null, null, null, this.additionalParameters)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("requestedTokenType cannot be empty"); + // @formatter:on + } + + @Test + public void constructorWhenSubjectTokenNullOrEmptyThenThrowIllegalArgumentException() { + // @formatter:off + assertThatThrownBy(() -> new OAuth2TokenExchangeAuthenticationToken(JWT_TOKEN_TYPE_VALUE, null, null, this.clientPrincipal, null, null, null, null, this.scopes, this.additionalParameters)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("subjectToken cannot be empty"); + assertThatThrownBy(() -> new OAuth2TokenExchangeAuthenticationToken(JWT_TOKEN_TYPE_VALUE, "", null, this.clientPrincipal, null, null, null, null, this.scopes, this.additionalParameters)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("subjectToken cannot be empty"); + // @formatter:on + } + + @Test + public void constructorWhenSubjectTokenTypeNullOrEmptyThenThrowIllegalArgumentException() { + // @formatter:off + assertThatThrownBy(() -> new OAuth2TokenExchangeAuthenticationToken(JWT_TOKEN_TYPE_VALUE, SUBJECT_TOKEN, null, this.clientPrincipal, null, null, null, null, this.scopes, this.additionalParameters)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("subjectTokenType cannot be empty"); + assertThatThrownBy(() -> new OAuth2TokenExchangeAuthenticationToken(JWT_TOKEN_TYPE_VALUE, SUBJECT_TOKEN, "", this.clientPrincipal, null, null, null, null, this.scopes, this.additionalParameters)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("subjectTokenType cannot be empty"); + // @formatter:on + } + + @Test + public void constructorWhenRequiredParametersProvidedThenCreated() { + OAuth2TokenExchangeAuthenticationToken authentication = new OAuth2TokenExchangeAuthenticationToken( + JWT_TOKEN_TYPE_VALUE, SUBJECT_TOKEN, ACCESS_TOKEN_TYPE_VALUE, this.clientPrincipal, null, null, null, + null, null, this.additionalParameters); + assertThat(authentication.getPrincipal()).isEqualTo(this.clientPrincipal); + assertThat(authentication.getCredentials().toString()).isEmpty(); + assertThat(authentication.getGrantType()).isEqualTo(AuthorizationGrantType.TOKEN_EXCHANGE); + assertThat(authentication.getRequestedTokenType()).isEqualTo(JWT_TOKEN_TYPE_VALUE); + assertThat(authentication.getSubjectToken()).isEqualTo(SUBJECT_TOKEN); + assertThat(authentication.getSubjectTokenType()).isEqualTo(ACCESS_TOKEN_TYPE_VALUE); + assertThat(authentication.getActorToken()).isNull(); + assertThat(authentication.getActorTokenType()).isNull(); + assertThat(authentication.getResources()).isEmpty(); + assertThat(authentication.getAudiences()).isEmpty(); + assertThat(authentication.getScopes()).isEmpty(); + assertThat(authentication.getAdditionalParameters()).isEqualTo(this.additionalParameters); + } + + @Test + public void constructorWhenAllParametersProvidedThenCreated() { + OAuth2TokenExchangeAuthenticationToken authentication = new OAuth2TokenExchangeAuthenticationToken( + JWT_TOKEN_TYPE_VALUE, SUBJECT_TOKEN, ACCESS_TOKEN_TYPE_VALUE, this.clientPrincipal, ACTOR_TOKEN, + ACCESS_TOKEN_TYPE_VALUE, RESOURCES, AUDIENCES, this.scopes, this.additionalParameters); + assertThat(authentication.getPrincipal()).isEqualTo(this.clientPrincipal); + assertThat(authentication.getCredentials().toString()).isEmpty(); + assertThat(authentication.getGrantType()).isEqualTo(AuthorizationGrantType.TOKEN_EXCHANGE); + assertThat(authentication.getRequestedTokenType()).isEqualTo(JWT_TOKEN_TYPE_VALUE); + assertThat(authentication.getSubjectToken()).isEqualTo(SUBJECT_TOKEN); + assertThat(authentication.getSubjectTokenType()).isEqualTo(ACCESS_TOKEN_TYPE_VALUE); + assertThat(authentication.getActorToken()).isEqualTo(ACTOR_TOKEN); + assertThat(authentication.getActorTokenType()).isEqualTo(ACCESS_TOKEN_TYPE_VALUE); + assertThat(authentication.getResources()).isEqualTo(RESOURCES); + assertThat(authentication.getAudiences()).isEqualTo(AUDIENCES); + assertThat(authentication.getScopes()).isEqualTo(this.scopes); + assertThat(authentication.getAdditionalParameters()).isEqualTo(this.additionalParameters); + } + +} diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeCompositeAuthenticationTokenTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeCompositeAuthenticationTokenTests.java new file mode 100644 index 00000000..99ad0e72 --- /dev/null +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeCompositeAuthenticationTokenTests.java @@ -0,0 +1,66 @@ +/* + * Copyright 2020-2024 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 java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import org.springframework.security.authentication.TestingAuthenticationToken; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests for {@link OAuth2TokenExchangeCompositeAuthenticationToken}. + * + * @author Steve Riesenberg + */ +public class OAuth2TokenExchangeCompositeAuthenticationTokenTests { + + @Test + public void constructorWhenSubjectNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> new OAuth2TokenExchangeCompositeAuthenticationToken(null, null)) + .withMessage("subject cannot be null"); + // @formatter:on + } + + @Test + public void constructorWhenActorsNullThenThrowIllegalArgumentException() { + TestingAuthenticationToken subject = new TestingAuthenticationToken("subject", null); + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> new OAuth2TokenExchangeCompositeAuthenticationToken(subject, null)) + .withMessage("actors cannot be null"); + // @formatter:on + } + + @Test + public void constructorWhenRequiredParametersProvidedThenCreated() { + TestingAuthenticationToken subject = new TestingAuthenticationToken("subject", null); + OAuth2TokenExchangeActor actor1 = new OAuth2TokenExchangeActor(Map.of("claim1", "value1")); + OAuth2TokenExchangeActor actor2 = new OAuth2TokenExchangeActor(Map.of("claim2", "value2")); + List actors = List.of(actor1, actor2); + OAuth2TokenExchangeCompositeAuthenticationToken authentication = + new OAuth2TokenExchangeCompositeAuthenticationToken(subject, actors); + assertThat(authentication.getSubject()).isEqualTo(subject); + assertThat(authentication.getActors()).isEqualTo(actors); + } + +} diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2TokenExchangeGrantTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2TokenExchangeGrantTests.java new file mode 100644 index 00000000..8a89c4b3 --- /dev/null +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2TokenExchangeGrantTests.java @@ -0,0 +1,368 @@ +/* + * Copyright 2020-2024 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.config.annotation.web.configurers; + +import java.security.Principal; +import java.time.Instant; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.function.Consumer; +import java.util.function.Function; + +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.proc.SecurityContext; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +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.Import; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.jdbc.core.JdbcOperations; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabase; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType; +import org.springframework.mock.http.client.MockClientHttpResponse; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.crypto.password.NoOpPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2Token; +import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.core.http.converter.OAuth2AccessTokenResponseHttpMessageConverter; +import org.springframework.security.oauth2.jose.TestJwks; +import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationConsentService; +import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationService; +import org.springframework.security.oauth2.server.authorization.OAuth2Authorization; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; +import org.springframework.security.oauth2.server.authorization.OAuth2TokenType; +import org.springframework.security.oauth2.server.authorization.TestOAuth2Authorizations; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2TokenExchangeCompositeAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.client.JdbcRegisteredClientRepository; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; +import org.springframework.security.oauth2.server.authorization.client.TestRegisteredClients; +import org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration; +import org.springframework.security.oauth2.server.authorization.test.SpringTestContext; +import org.springframework.security.oauth2.server.authorization.test.SpringTestContextExtension; +import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenClaimNames; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Integration tests for OAuth 2.0 Token Exchange Grant. + * + * @author Steve Riesenberg + */ +@ExtendWith(SpringTestContextExtension.class) +public class OAuth2TokenExchangeGrantTests { + private static final String DEFAULT_TOKEN_ENDPOINT_URI = "/oauth2/token"; + private static final String RESOURCE = "https://mydomain.com/resource"; + private static final String AUDIENCE = "audience"; + private static final String SUBJECT_TOKEN = "EfYu_0jEL"; + private static final String ACTOR_TOKEN = "JlNE_xR1f"; + private static final String ACCESS_TOKEN_TYPE_VALUE = "urn:ietf:params:oauth:token-type:access_token"; + private static final String JWT_TOKEN_TYPE_VALUE = "urn:ietf:params:oauth:token-type:jwt"; + + public final SpringTestContext spring = new SpringTestContext(); + + private final HttpMessageConverter accessTokenResponseHttpMessageConverter = + new OAuth2AccessTokenResponseHttpMessageConverter(); + + @Autowired + private MockMvc mvc; + + @Autowired + private JdbcOperations jdbcOperations; + + @Autowired + private RegisteredClientRepository registeredClientRepository; + + @Autowired + private OAuth2AuthorizationService authorizationService; + + @BeforeAll + public static void init() { + JWKSet jwkSet = new JWKSet(TestJwks.DEFAULT_RSA_JWK); + AuthorizationServerConfiguration.JWK_SOURCE = (jwkSelector, securityContext) -> jwkSelector.select(jwkSet); + // @formatter:off + AuthorizationServerConfiguration.DB = new EmbeddedDatabaseBuilder() + .generateUniqueName(true) + .setType(EmbeddedDatabaseType.HSQL) + .setScriptEncoding("UTF-8") + .addScript("org/springframework/security/oauth2/server/authorization/oauth2-authorization-schema.sql") + .addScript("org/springframework/security/oauth2/server/authorization/oauth2-authorization-consent-schema.sql") + .addScript("org/springframework/security/oauth2/server/authorization/client/oauth2-registered-client-schema.sql") + .build(); + // @formatter:on + } + + @AfterEach + public void tearDown() { + this.jdbcOperations.update("truncate table oauth2_authorization"); + this.jdbcOperations.update("truncate table oauth2_authorization_consent"); + this.jdbcOperations.update("truncate table oauth2_registered_client"); + } + + @AfterAll + public static void destroy() { + AuthorizationServerConfiguration.DB.shutdown(); + } + + @Test + public void requestWhenAccessTokenRequestNotAuthenticatedThenUnauthorized() throws Exception { + this.spring.register(AuthorizationServerConfiguration.class).autowire(); + + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .authorizationGrantType(AuthorizationGrantType.TOKEN_EXCHANGE).build(); + this.registeredClientRepository.save(registeredClient); + + MultiValueMap parameters = new LinkedMultiValueMap<>(); + parameters.set(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.TOKEN_EXCHANGE.getValue()); + parameters.set(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId()); + parameters.set(OAuth2ParameterNames.SCOPE, + StringUtils.collectionToDelimitedString(registeredClient.getScopes(), " ")); + + // @formatter:off + this.mvc.perform(post(DEFAULT_TOKEN_ENDPOINT_URI).params(parameters)) + .andExpect(status().isUnauthorized()); + // @formatter:on + } + + @Test + public void requestWhenAccessTokenRequestValidAndNoActorTokenThenReturnAccessTokenResponseForImpersonation() throws Exception { + this.spring.register(AuthorizationServerConfiguration.class).autowire(); + + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .authorizationGrantType(AuthorizationGrantType.TOKEN_EXCHANGE).build(); + this.registeredClientRepository.save(registeredClient); + + UsernamePasswordAuthenticationToken userPrincipal = createUserPrincipal("user"); + OAuth2Authorization subjectAuthorization = TestOAuth2Authorizations.authorization(registeredClient) + .attribute(Principal.class.getName(), userPrincipal).build(); + this.authorizationService.save(subjectAuthorization); + + MultiValueMap parameters = new LinkedMultiValueMap<>(); + parameters.set(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.TOKEN_EXCHANGE.getValue()); + parameters.set(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId()); + parameters.set(OAuth2ParameterNames.REQUESTED_TOKEN_TYPE, JWT_TOKEN_TYPE_VALUE); + parameters.set(OAuth2ParameterNames.SUBJECT_TOKEN, subjectAuthorization.getAccessToken().getToken().getTokenValue()); + parameters.set(OAuth2ParameterNames.SUBJECT_TOKEN_TYPE, JWT_TOKEN_TYPE_VALUE); + parameters.set(OAuth2ParameterNames.RESOURCE, RESOURCE); + parameters.set(OAuth2ParameterNames.AUDIENCE, AUDIENCE); + parameters.set(OAuth2ParameterNames.SCOPE, + StringUtils.collectionToDelimitedString(registeredClient.getScopes(), " ")); + + // @formatter:off + MvcResult mvcResult = this.mvc.perform(post(DEFAULT_TOKEN_ENDPOINT_URI) + .params(parameters) + .headers(withClientAuth(registeredClient))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.access_token").isNotEmpty()) + .andExpect(jsonPath("$.refresh_token").doesNotExist()) + .andExpect(jsonPath("$.expires_in").isNumber()) + .andExpect(jsonPath("$.scope").isNotEmpty()) + .andExpect(jsonPath("$.token_type").isNotEmpty()) + .andExpect(jsonPath("$.issued_token_type").isNotEmpty()) + .andReturn(); + // @formatter:on + + MockHttpServletResponse servletResponse = mvcResult.getResponse(); + MockClientHttpResponse httpResponse = new MockClientHttpResponse(servletResponse.getContentAsByteArray(), + HttpStatus.OK); + OAuth2AccessTokenResponse accessTokenResponse = + this.accessTokenResponseHttpMessageConverter.read(OAuth2AccessTokenResponse.class, httpResponse); + + String accessToken = accessTokenResponse.getAccessToken().getTokenValue(); + OAuth2Authorization authorization = this.authorizationService.findByToken(accessToken, + OAuth2TokenType.ACCESS_TOKEN); + assertThat(authorization).isNotNull(); + assertThat(authorization.getAccessToken()).isNotNull(); + assertThat(authorization.getAccessToken().getClaims()).isNotNull(); + // We do not populate claims (e.g. `aud`) based on the resource or audience parameters + assertThat(authorization.getAccessToken().getClaims().get(OAuth2TokenClaimNames.AUD)) + .isEqualTo(List.of(registeredClient.getClientId())); + assertThat(authorization.getRefreshToken()).isNull(); + assertThat(authorization.getAttribute(Principal.class.getName())).isEqualTo(userPrincipal); + } + + @Test + public void requestWhenAccessTokenRequestValidAndActorTokenThenReturnAccessTokenResponseForDelegation() throws Exception { + this.spring.register(AuthorizationServerConfiguration.class).autowire(); + + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .authorizationGrantType(AuthorizationGrantType.TOKEN_EXCHANGE).build(); + this.registeredClientRepository.save(registeredClient); + + UsernamePasswordAuthenticationToken userPrincipal = createUserPrincipal("user"); + UsernamePasswordAuthenticationToken adminPrincipal = createUserPrincipal("admin"); + Map actorTokenClaims = new HashMap<>(); + actorTokenClaims.put(OAuth2TokenClaimNames.ISS, "issuer2"); + actorTokenClaims.put(OAuth2TokenClaimNames.SUB, "admin"); + Map subjectTokenClaims = new HashMap<>(); + subjectTokenClaims.put(OAuth2TokenClaimNames.ISS, "issuer1"); + subjectTokenClaims.put(OAuth2TokenClaimNames.SUB, "user"); + subjectTokenClaims.put("may_act", actorTokenClaims); + OAuth2AccessToken subjectToken = createAccessToken(SUBJECT_TOKEN); + OAuth2AccessToken actorToken = createAccessToken(ACTOR_TOKEN); + // @formatter:off + OAuth2Authorization subjectAuthorization = TestOAuth2Authorizations.authorization(registeredClient, subjectToken, subjectTokenClaims) + .id(UUID.randomUUID().toString()) + .attribute(Principal.class.getName(), userPrincipal) + .build(); + OAuth2Authorization actorAuthorization = TestOAuth2Authorizations.authorization(registeredClient, actorToken, actorTokenClaims) + .id(UUID.randomUUID().toString()) + .attribute(Principal.class.getName(), adminPrincipal) + .build(); + // @formatter:on + this.authorizationService.save(subjectAuthorization); + this.authorizationService.save(actorAuthorization); + + MultiValueMap parameters = new LinkedMultiValueMap<>(); + parameters.set(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.TOKEN_EXCHANGE.getValue()); + parameters.set(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId()); + parameters.set(OAuth2ParameterNames.REQUESTED_TOKEN_TYPE, JWT_TOKEN_TYPE_VALUE); + parameters.set(OAuth2ParameterNames.SUBJECT_TOKEN, SUBJECT_TOKEN); + parameters.set(OAuth2ParameterNames.SUBJECT_TOKEN_TYPE, JWT_TOKEN_TYPE_VALUE); + parameters.set(OAuth2ParameterNames.ACTOR_TOKEN, ACTOR_TOKEN); + parameters.set(OAuth2ParameterNames.ACTOR_TOKEN_TYPE, ACCESS_TOKEN_TYPE_VALUE); + parameters.set(OAuth2ParameterNames.SCOPE, + StringUtils.collectionToDelimitedString(registeredClient.getScopes(), " ")); + + // @formatter:off + MvcResult mvcResult = this.mvc.perform(post(DEFAULT_TOKEN_ENDPOINT_URI) + .params(parameters) + .headers(withClientAuth(registeredClient))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.access_token").isNotEmpty()) + .andExpect(jsonPath("$.refresh_token").doesNotExist()) + .andExpect(jsonPath("$.expires_in").isNumber()) + .andExpect(jsonPath("$.scope").isNotEmpty()) + .andExpect(jsonPath("$.token_type").isNotEmpty()) + .andExpect(jsonPath("$.issued_token_type").isNotEmpty()) + .andReturn(); + // @formatter:on + + MockHttpServletResponse servletResponse = mvcResult.getResponse(); + MockClientHttpResponse httpResponse = new MockClientHttpResponse(servletResponse.getContentAsByteArray(), + HttpStatus.OK); + OAuth2AccessTokenResponse accessTokenResponse = + this.accessTokenResponseHttpMessageConverter.read(OAuth2AccessTokenResponse.class, httpResponse); + + String accessToken = accessTokenResponse.getAccessToken().getTokenValue(); + OAuth2Authorization authorization = this.authorizationService.findByToken(accessToken, + OAuth2TokenType.ACCESS_TOKEN); + assertThat(authorization).isNotNull(); + assertThat(authorization.getAccessToken()).isNotNull(); + assertThat(authorization.getAccessToken().getClaims()).isNotNull(); + assertThat(authorization.getAccessToken().getClaims().get("act")).isNotNull(); + assertThat(authorization.getRefreshToken()).isNull(); + assertThat(authorization.getAttribute(Principal.class.getName())) + .isInstanceOf(OAuth2TokenExchangeCompositeAuthenticationToken.class); + } + + private static OAuth2AccessToken createAccessToken(String tokenValue) { + Instant issuedAt = Instant.now(); + Instant expiresAt = issuedAt.plusSeconds(300); + return new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, tokenValue, issuedAt, expiresAt); + } + + private static UsernamePasswordAuthenticationToken createUserPrincipal(String username) { + User user = new User(username, "", AuthorityUtils.createAuthorityList("ROLE_USER")); + return UsernamePasswordAuthenticationToken.authenticated(user, null, user.getAuthorities()); + } + + private static HttpHeaders withClientAuth(RegisteredClient registeredClient) { + HttpHeaders headers = new HttpHeaders(); + headers.setBasicAuth(registeredClient.getClientId(), registeredClient.getClientSecret()); + return headers; + } + + private static Consumer> withInvalidated() { + return (metadata) -> metadata.put(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME, true); + } + + private static Function, Boolean> isInvalidated() { + return (token) -> token.getMetadata(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME); + } + + @EnableWebSecurity + @Import(OAuth2AuthorizationServerConfiguration.class) + static class AuthorizationServerConfiguration { + + static JWKSource JWK_SOURCE; + + static EmbeddedDatabase DB; + + @Bean + RegisteredClientRepository registeredClientRepository(JdbcOperations jdbcOperations) { + return new JdbcRegisteredClientRepository(jdbcOperations); + } + + @Bean + OAuth2AuthorizationService authorizationService(JdbcOperations jdbcOperations, + RegisteredClientRepository registeredClientRepository) { + return new JdbcOAuth2AuthorizationService(jdbcOperations, registeredClientRepository); + } + + @Bean + OAuth2AuthorizationConsentService authorizationConsentService(JdbcOperations jdbcOperations, + RegisteredClientRepository registeredClientRepository) { + return new JdbcOAuth2AuthorizationConsentService(jdbcOperations, registeredClientRepository); + } + + @Bean + JdbcOperations jdbcOperations() { + return new JdbcTemplate(DB); + } + + @Bean + JWKSource jwkSource() { + return JWK_SOURCE; + } + + @Bean + PasswordEncoder passwordEncoder() { + return NoOpPasswordEncoder.getInstance(); + } + } + +} diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/token/DefaultOAuth2TokenClaimsConsumerTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/token/DefaultOAuth2TokenClaimsConsumerTests.java new file mode 100644 index 00000000..5458b69a --- /dev/null +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/token/DefaultOAuth2TokenClaimsConsumerTests.java @@ -0,0 +1,128 @@ +/* + * Copyright 2020-2024 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.token; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Consumer; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.server.authorization.OAuth2TokenType; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2TokenExchangeActor; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2TokenExchangeAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2TokenExchangeCompositeAuthenticationToken; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Tests for {@link DefaultOAuth2TokenClaimsConsumer}. + * + * @author Steve Riesenberg + */ +public class DefaultOAuth2TokenClaimsConsumerTests { + + private OAuth2TokenContext tokenContext; + + private Consumer> consumer; + + @BeforeEach + public void setUp() { + this.tokenContext = mock(OAuth2TokenContext.class); + this.consumer = new DefaultOAuth2TokenClaimsConsumer(this.tokenContext); + } + + @Test + public void acceptWhenTokenTypeIsRefreshTokenThenNoClaimsAdded() { + when(this.tokenContext.getTokenType()).thenReturn(OAuth2TokenType.REFRESH_TOKEN); + Map claims = new LinkedHashMap<>(); + this.consumer.accept(claims); + assertThat(claims).isEmpty(); + } + + @Test + public void acceptWhenAuthorizationGrantIsNullThenNoClaimsAdded() { + when(this.tokenContext.getTokenType()).thenReturn(OAuth2TokenType.ACCESS_TOKEN); + when(this.tokenContext.getAuthorizationGrant()).thenReturn(null); + Map claims = new LinkedHashMap<>(); + this.consumer.accept(claims); + assertThat(claims).isEmpty(); + } + + @Test + public void acceptWhenTokenExchangeGrantAndResourcesThenNoClaimsAdded() { + OAuth2TokenExchangeAuthenticationToken tokenExchangeAuthentication = mock( + OAuth2TokenExchangeAuthenticationToken.class); + when(tokenExchangeAuthentication.getResources()).thenReturn(Set.of("resource1", "resource2")); + when(this.tokenContext.getTokenType()).thenReturn(OAuth2TokenType.ACCESS_TOKEN); + when(this.tokenContext.getAuthorizationGrant()).thenReturn(tokenExchangeAuthentication); + Map claims = new LinkedHashMap<>(); + this.consumer.accept(claims); + // We do not populate claims (e.g. `aud`) based on the resource parameter + assertThat(claims).isEmpty(); + } + + @Test + public void acceptWhenTokenExchangeGrantAndAudiencesThenNoClaimsAdded() { + OAuth2TokenExchangeAuthenticationToken tokenExchangeAuthentication = mock( + OAuth2TokenExchangeAuthenticationToken.class); + when(tokenExchangeAuthentication.getAudiences()).thenReturn(Set.of("audience1", "audience2")); + when(this.tokenContext.getTokenType()).thenReturn(OAuth2TokenType.ACCESS_TOKEN); + when(this.tokenContext.getAuthorizationGrant()).thenReturn(tokenExchangeAuthentication); + Map claims = new LinkedHashMap<>(); + this.consumer.accept(claims); + // NOTE: We do not populate claims (e.g. `aud`) based on the audience parameter + assertThat(claims).isEmpty(); + } + + @Test + public void acceptWhenTokenExchangeGrantAndDelegationThenActClaimAdded() { + OAuth2TokenExchangeAuthenticationToken tokenExchangeAuthentication = mock( + OAuth2TokenExchangeAuthenticationToken.class); + when(tokenExchangeAuthentication.getAudiences()).thenReturn(Collections.emptySet()); + when(this.tokenContext.getTokenType()).thenReturn(OAuth2TokenType.ACCESS_TOKEN); + when(this.tokenContext.getAuthorizationGrant()).thenReturn(tokenExchangeAuthentication); + Authentication subject = new TestingAuthenticationToken("subject", null); + OAuth2TokenExchangeActor actor1 = new OAuth2TokenExchangeActor(Map.of(OAuth2TokenClaimNames.ISS, "issuer1", + OAuth2TokenClaimNames.SUB, "actor1")); + OAuth2TokenExchangeActor actor2 = new OAuth2TokenExchangeActor(Map.of(OAuth2TokenClaimNames.ISS, "issuer2", + OAuth2TokenClaimNames.SUB, "actor2")); + OAuth2TokenExchangeCompositeAuthenticationToken principal = new OAuth2TokenExchangeCompositeAuthenticationToken( + subject, List.of(actor1, actor2)); + when(this.tokenContext.getPrincipal()).thenReturn(principal); + Map claims = new LinkedHashMap<>(); + this.consumer.accept(claims); + assertThat(claims).hasSize(1); + assertThat(claims.get("act")).isNotNull(); + @SuppressWarnings("unchecked") + Map actClaim1 = (Map) claims.get("act"); + assertThat(actClaim1.get(OAuth2TokenClaimNames.ISS)).isEqualTo("issuer1"); + assertThat(actClaim1.get(OAuth2TokenClaimNames.SUB)).isEqualTo("actor1"); + @SuppressWarnings("unchecked") + Map actClaim2 = (Map) actClaim1.get("act"); + assertThat(actClaim2.get(OAuth2TokenClaimNames.ISS)).isEqualTo("issuer2"); + assertThat(actClaim2.get(OAuth2TokenClaimNames.SUB)).isEqualTo("actor2"); + } + +} diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2TokenEndpointFilterTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2TokenEndpointFilterTests.java index 824b9f10..b26fcbdb 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2TokenEndpointFilterTests.java +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2TokenEndpointFilterTests.java @@ -21,11 +21,10 @@ import java.util.Arrays; import java.util.HashSet; import java.util.Map; +import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.servlet.FilterChain; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; - -import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -56,6 +55,7 @@ import org.springframework.security.oauth2.server.authorization.authentication.O import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientAuthenticationToken; import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientCredentialsAuthenticationToken; import org.springframework.security.oauth2.server.authorization.authentication.OAuth2RefreshTokenAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2TokenExchangeAuthenticationToken; import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; import org.springframework.security.oauth2.server.authorization.client.TestRegisteredClients; import org.springframework.security.web.authentication.AuthenticationConverter; @@ -84,6 +84,7 @@ import static org.mockito.Mockito.when; public class OAuth2TokenEndpointFilterTests { private static final String DEFAULT_TOKEN_ENDPOINT_URI = "/oauth2/token"; private static final String REMOTE_ADDRESS = "remote-address"; + private static final String ACCESS_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token"; private AuthenticationManager authenticationManager; private OAuth2TokenEndpointFilter filter; private final HttpMessageConverter errorHttpResponseConverter = @@ -453,6 +454,70 @@ public class OAuth2TokenEndpointFilterTests { assertThat(refreshTokenResult.getTokenValue()).isEqualTo(refreshToken.getTokenValue()); } + @Test + public void doFilterWhenTokenExchangeRequestThenAccessTokenResponse() throws Exception { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .authorizationGrantType(AuthorizationGrantType.TOKEN_EXCHANGE).build(); + Authentication clientPrincipal = new OAuth2ClientAuthenticationToken( + registeredClient, ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret()); + OAuth2AccessToken accessToken = new OAuth2AccessToken( + OAuth2AccessToken.TokenType.BEARER, "token", + Instant.now(), Instant.now().plus(Duration.ofHours(1)), + new HashSet<>(Arrays.asList("scope1", "scope2"))); + OAuth2RefreshToken refreshToken = new OAuth2RefreshToken("refresh-token", Instant.now()); + OAuth2AccessTokenAuthenticationToken accessTokenAuthentication = + new OAuth2AccessTokenAuthenticationToken( + registeredClient, clientPrincipal, accessToken, refreshToken); + + when(this.authenticationManager.authenticate(any())).thenReturn(accessTokenAuthentication); + + SecurityContext securityContext = SecurityContextHolder.createEmptyContext(); + securityContext.setAuthentication(clientPrincipal); + SecurityContextHolder.setContext(securityContext); + + MockHttpServletRequest request = createTokenExchangeTokenRequest(registeredClient); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + + this.filter.doFilter(request, response, filterChain); + + verifyNoInteractions(filterChain); + + ArgumentCaptor tokenExchangeAuthenticationCaptor = + ArgumentCaptor.forClass(OAuth2TokenExchangeAuthenticationToken.class); + verify(this.authenticationManager).authenticate(tokenExchangeAuthenticationCaptor.capture()); + + OAuth2TokenExchangeAuthenticationToken tokenExchangeAuthenticationToken = + tokenExchangeAuthenticationCaptor.getValue(); + assertThat(tokenExchangeAuthenticationToken.getSubjectToken()).isEqualTo("subject-token"); + assertThat(tokenExchangeAuthenticationToken.getSubjectTokenType()).isEqualTo(ACCESS_TOKEN_TYPE); + assertThat(tokenExchangeAuthenticationToken.getPrincipal()).isEqualTo(clientPrincipal); + assertThat(tokenExchangeAuthenticationToken.getScopes()).isEqualTo(registeredClient.getScopes()); + assertThat(tokenExchangeAuthenticationToken.getAdditionalParameters()) + .containsExactly(entry("custom-param-1", "custom-value-1"), + entry("custom-param-2", new String[] { "custom-value-1", "custom-value-2" })); + assertThat(tokenExchangeAuthenticationToken.getDetails()) + .asInstanceOf(type(WebAuthenticationDetails.class)) + .extracting(WebAuthenticationDetails::getRemoteAddress) + .isEqualTo(REMOTE_ADDRESS); + + + assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value()); + OAuth2AccessTokenResponse accessTokenResponse = readAccessTokenResponse(response); + + OAuth2AccessToken accessTokenResult = accessTokenResponse.getAccessToken(); + assertThat(accessTokenResult.getTokenType()).isEqualTo(accessToken.getTokenType()); + assertThat(accessTokenResult.getTokenValue()).isEqualTo(accessToken.getTokenValue()); + assertThat(accessTokenResult.getIssuedAt()).isBetween( + accessToken.getIssuedAt().minusSeconds(1), accessToken.getIssuedAt().plusSeconds(1)); + assertThat(accessTokenResult.getExpiresAt()).isBetween( + accessToken.getExpiresAt().minusSeconds(1), accessToken.getExpiresAt().plusSeconds(1)); + assertThat(accessTokenResult.getScopes()).isEqualTo(accessToken.getScopes()); + + OAuth2RefreshToken refreshTokenResult = accessTokenResponse.getRefreshToken(); + assertThat(refreshTokenResult.getTokenValue()).isEqualTo(refreshToken.getTokenValue()); + } + @Test public void doFilterWhenCustomAuthenticationDetailsSourceThenUsed() throws Exception { RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); @@ -648,4 +713,21 @@ public class OAuth2TokenEndpointFilterTests { return request; } + + private static MockHttpServletRequest createTokenExchangeTokenRequest(RegisteredClient registeredClient) { + String requestUri = DEFAULT_TOKEN_ENDPOINT_URI; + MockHttpServletRequest request = new MockHttpServletRequest("POST", requestUri); + request.setServletPath(requestUri); + request.setRemoteAddr(REMOTE_ADDRESS); + + request.addParameter(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.TOKEN_EXCHANGE.getValue()); + request.addParameter(OAuth2ParameterNames.SUBJECT_TOKEN, "subject-token"); + request.addParameter(OAuth2ParameterNames.SUBJECT_TOKEN_TYPE, ACCESS_TOKEN_TYPE); + request.addParameter(OAuth2ParameterNames.SCOPE, + StringUtils.collectionToDelimitedString(registeredClient.getScopes(), " ")); + request.addParameter("custom-param-1", "custom-value-1"); + request.addParameter("custom-param-2", "custom-value-1", "custom-value-2"); + + return request; + } } diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2TokenExchangeAuthenticationConverterTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2TokenExchangeAuthenticationConverterTests.java new file mode 100644 index 00000000..ba7360ac --- /dev/null +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2TokenExchangeAuthenticationConverterTests.java @@ -0,0 +1,336 @@ +/* + * Copyright 2020-2024 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.authentication; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.http.HttpMethod; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.context.SecurityContextImpl; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +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.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2TokenExchangeAuthenticationToken; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * Tests for {@link OAuth2TokenExchangeAuthenticationConverter}. + * + * @author Steve Riesenberg + */ +public class OAuth2TokenExchangeAuthenticationConverterTests { + private static final String CLIENT_ID = "client-1"; + private static final String TOKEN_URI = "/oauth2/token"; + private static final String SUBJECT_TOKEN = "EfYu_0jEL"; + private static final String ACTOR_TOKEN = "JlNE_xR1f"; + private static final String ACCESS_TOKEN_TYPE_VALUE = "urn:ietf:params:oauth:token-type:access_token"; + private static final String JWT_TOKEN_TYPE_VALUE = "urn:ietf:params:oauth:token-type:jwt"; + + private OAuth2TokenExchangeAuthenticationConverter converter; + + @BeforeEach + public void setUp() { + this.converter = new OAuth2TokenExchangeAuthenticationConverter(); + } + + @AfterEach + public void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + public void convertWhenMissingGrantTypeThenReturnNull() { + MockHttpServletRequest request = createRequest(); + Authentication authentication = this.converter.convert(request); + assertThat(authentication).isNull(); + } + + @Test + public void convertWhenInvalidResourceThenInvalidRequestError() { + MockHttpServletRequest request = createRequest(); + request.addParameter(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.TOKEN_EXCHANGE.getValue()); + request.addParameter(OAuth2ParameterNames.RESOURCE, "invalid"); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.converter.convert(request)) + .withMessageContaining(OAuth2ParameterNames.RESOURCE) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST); + // @formatter:on + } + + @Test + public void convertWhenResourceContainsFragmentThenInvalidRequestError() { + MockHttpServletRequest request = createRequest(); + request.addParameter(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.TOKEN_EXCHANGE.getValue()); + request.addParameter(OAuth2ParameterNames.RESOURCE, "https://mydomain.com/#fragment"); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.converter.convert(request)) + .withMessageContaining(OAuth2ParameterNames.RESOURCE) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST); + // @formatter:on + } + + @Test + public void convertWhenMultipleScopeParametersThenInvalidRequestError() { + MockHttpServletRequest request = createRequest(); + request.addParameter(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.TOKEN_EXCHANGE.getValue()); + request.addParameter(OAuth2ParameterNames.SCOPE, "one"); + request.addParameter(OAuth2ParameterNames.SCOPE, "two"); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.converter.convert(request)) + .withMessageContaining(OAuth2ParameterNames.SCOPE) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST); + // @formatter:on + } + + @Test + public void convertWhenMultipleRequestedTokenTypeParametersThenInvalidRequestError() { + MockHttpServletRequest request = createRequest(); + request.addParameter(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.TOKEN_EXCHANGE.getValue()); + request.addParameter(OAuth2ParameterNames.REQUESTED_TOKEN_TYPE, ACCESS_TOKEN_TYPE_VALUE); + request.addParameter(OAuth2ParameterNames.REQUESTED_TOKEN_TYPE, JWT_TOKEN_TYPE_VALUE); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.converter.convert(request)) + .withMessageContaining(OAuth2ParameterNames.REQUESTED_TOKEN_TYPE) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST); + // @formatter:on + } + + @Test + public void convertWhenInvalidRequestedTokenTypeThenUnsupportedTokenTypeError() { + MockHttpServletRequest request = createRequest(); + request.addParameter(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.TOKEN_EXCHANGE.getValue()); + request.addParameter(OAuth2ParameterNames.REQUESTED_TOKEN_TYPE, "invalid"); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.converter.convert(request)) + .withMessageContaining(OAuth2ParameterNames.REQUESTED_TOKEN_TYPE) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.UNSUPPORTED_TOKEN_TYPE); + // @formatter:on + } + + @Test + public void convertWhenMissingSubjectTokenThenInvalidRequestError() { + MockHttpServletRequest request = createRequest(); + request.addParameter(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.TOKEN_EXCHANGE.getValue()); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.converter.convert(request)) + .withMessageContaining(OAuth2ParameterNames.SUBJECT_TOKEN) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST); + // @formatter:on + } + + @Test + public void convertWhenMultipleSubjectTokenParametersThenInvalidRequestError() { + MockHttpServletRequest request = createRequest(); + request.addParameter(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.TOKEN_EXCHANGE.getValue()); + request.addParameter(OAuth2ParameterNames.SUBJECT_TOKEN, SUBJECT_TOKEN); + request.addParameter(OAuth2ParameterNames.SUBJECT_TOKEN, "another"); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.converter.convert(request)) + .withMessageContaining(OAuth2ParameterNames.SUBJECT_TOKEN) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST); + // @formatter:on + } + + @Test + public void convertWhenMissingSubjectTokenTypeThenInvalidRequestError() { + MockHttpServletRequest request = createRequest(); + request.addParameter(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.TOKEN_EXCHANGE.getValue()); + request.addParameter(OAuth2ParameterNames.SUBJECT_TOKEN, SUBJECT_TOKEN); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.converter.convert(request)) + .withMessageContaining(OAuth2ParameterNames.SUBJECT_TOKEN_TYPE) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST); + // @formatter:on + } + + @Test + public void convertWhenMultipleSubjectTokenTypeParametersThenInvalidRequestError() { + MockHttpServletRequest request = createRequest(); + request.addParameter(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.TOKEN_EXCHANGE.getValue()); + request.addParameter(OAuth2ParameterNames.SUBJECT_TOKEN, SUBJECT_TOKEN); + request.addParameter(OAuth2ParameterNames.SUBJECT_TOKEN_TYPE, ACCESS_TOKEN_TYPE_VALUE); + request.addParameter(OAuth2ParameterNames.SUBJECT_TOKEN_TYPE, JWT_TOKEN_TYPE_VALUE); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.converter.convert(request)) + .withMessageContaining(OAuth2ParameterNames.SUBJECT_TOKEN_TYPE) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST); + // @formatter:on + } + + @Test + public void convertWhenInvalidSubjectTokenTypeThenUnsupportedTokenTypeError() { + MockHttpServletRequest request = createRequest(); + request.addParameter(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.TOKEN_EXCHANGE.getValue()); + request.addParameter(OAuth2ParameterNames.SUBJECT_TOKEN, SUBJECT_TOKEN); + request.addParameter(OAuth2ParameterNames.SUBJECT_TOKEN_TYPE, "invalid"); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.converter.convert(request)) + .withMessageContaining(OAuth2ParameterNames.SUBJECT_TOKEN_TYPE) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.UNSUPPORTED_TOKEN_TYPE); + // @formatter:on + } + + + @Test + public void convertWhenMultipleActorTokenParametersThenInvalidRequestError() { + MockHttpServletRequest request = createRequest(); + request.addParameter(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.TOKEN_EXCHANGE.getValue()); + request.addParameter(OAuth2ParameterNames.SUBJECT_TOKEN, SUBJECT_TOKEN); + request.addParameter(OAuth2ParameterNames.SUBJECT_TOKEN_TYPE, ACCESS_TOKEN_TYPE_VALUE); + request.addParameter(OAuth2ParameterNames.ACTOR_TOKEN, ACTOR_TOKEN); + request.addParameter(OAuth2ParameterNames.ACTOR_TOKEN, "another"); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.converter.convert(request)) + .withMessageContaining(OAuth2ParameterNames.ACTOR_TOKEN) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST); + // @formatter:on + } + + @Test + public void convertWhenActorTokenAndMissingActorTokenTypeThenInvalidRequestError() { + MockHttpServletRequest request = createRequest(); + request.addParameter(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.TOKEN_EXCHANGE.getValue()); + request.addParameter(OAuth2ParameterNames.SUBJECT_TOKEN, SUBJECT_TOKEN); + request.addParameter(OAuth2ParameterNames.SUBJECT_TOKEN_TYPE, ACCESS_TOKEN_TYPE_VALUE); + request.addParameter(OAuth2ParameterNames.ACTOR_TOKEN, ACTOR_TOKEN); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.converter.convert(request)) + .withMessageContaining(OAuth2ParameterNames.ACTOR_TOKEN_TYPE) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST); + // @formatter:on + } + + @Test + public void convertWhenActorTokenTypeAndMissingActorTokenThenInvalidRequestError() { + MockHttpServletRequest request = createRequest(); + request.addParameter(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.TOKEN_EXCHANGE.getValue()); + request.addParameter(OAuth2ParameterNames.SUBJECT_TOKEN, SUBJECT_TOKEN); + request.addParameter(OAuth2ParameterNames.SUBJECT_TOKEN_TYPE, ACCESS_TOKEN_TYPE_VALUE); + request.addParameter(OAuth2ParameterNames.ACTOR_TOKEN_TYPE, JWT_TOKEN_TYPE_VALUE); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.converter.convert(request)) + .withMessageContaining(OAuth2ParameterNames.ACTOR_TOKEN) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST); + // @formatter:on + } + + @Test + public void convertWhenInvalidActorTokenTypeThenUnsupportedTokenTypeError() { + MockHttpServletRequest request = createRequest(); + request.addParameter(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.TOKEN_EXCHANGE.getValue()); + request.addParameter(OAuth2ParameterNames.SUBJECT_TOKEN, SUBJECT_TOKEN); + request.addParameter(OAuth2ParameterNames.SUBJECT_TOKEN_TYPE, ACCESS_TOKEN_TYPE_VALUE); + request.addParameter(OAuth2ParameterNames.ACTOR_TOKEN, ACTOR_TOKEN); + request.addParameter(OAuth2ParameterNames.ACTOR_TOKEN_TYPE, "invalid"); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.converter.convert(request)) + .withMessageContaining(OAuth2ParameterNames.ACTOR_TOKEN_TYPE) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.UNSUPPORTED_TOKEN_TYPE); + // @formatter:on + } + + @Test + public void convertWhenAllParametersThenTokenExchangeAuthenticationToken() { + MockHttpServletRequest request = createRequest(); + request.addParameter(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.TOKEN_EXCHANGE.getValue()); + request.addParameter(OAuth2ParameterNames.RESOURCE, "https://mydomain.com/resource1"); + request.addParameter(OAuth2ParameterNames.RESOURCE, "https://mydomain.com/resource2"); + request.addParameter(OAuth2ParameterNames.AUDIENCE, "audience1"); + request.addParameter(OAuth2ParameterNames.AUDIENCE, "audience2"); + request.addParameter(OAuth2ParameterNames.SCOPE, "one two"); + request.addParameter(OAuth2ParameterNames.REQUESTED_TOKEN_TYPE, JWT_TOKEN_TYPE_VALUE); + request.addParameter(OAuth2ParameterNames.SUBJECT_TOKEN, SUBJECT_TOKEN); + request.addParameter(OAuth2ParameterNames.SUBJECT_TOKEN_TYPE, ACCESS_TOKEN_TYPE_VALUE); + request.addParameter(OAuth2ParameterNames.ACTOR_TOKEN, ACTOR_TOKEN); + request.addParameter(OAuth2ParameterNames.ACTOR_TOKEN_TYPE, JWT_TOKEN_TYPE_VALUE); + + SecurityContextImpl securityContext = new SecurityContextImpl(); + securityContext.setAuthentication(new TestingAuthenticationToken(CLIENT_ID, null)); + SecurityContextHolder.setContext(securityContext); + + OAuth2TokenExchangeAuthenticationToken authentication = + (OAuth2TokenExchangeAuthenticationToken) this.converter.convert(request); + assertThat(authentication).isNotNull(); + assertThat(authentication.getResources()).containsExactly("https://mydomain.com/resource1", + "https://mydomain.com/resource2"); + assertThat(authentication.getAudiences()).containsExactly("audience1", "audience2"); + assertThat(authentication.getScopes()).containsExactly("one", "two"); + assertThat(authentication.getRequestedTokenType()).isEqualTo(JWT_TOKEN_TYPE_VALUE); + assertThat(authentication.getSubjectToken()).isEqualTo(SUBJECT_TOKEN); + assertThat(authentication.getSubjectTokenType()).isEqualTo(ACCESS_TOKEN_TYPE_VALUE); + assertThat(authentication.getActorToken()).isEqualTo(ACTOR_TOKEN); + assertThat(authentication.getActorTokenType()).isEqualTo(JWT_TOKEN_TYPE_VALUE); + } + + private static MockHttpServletRequest createRequest() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setMethod(HttpMethod.POST.name()); + request.setRequestURI(TOKEN_URI); + return request; + } + +}