Browse Source

Add tests for Token Exchange

Issue gh-60
Steve Riesenberg 1 year ago
parent
commit
ef859a3a51
9 changed files with 1871 additions and 4 deletions
  1. 5 2
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/TestOAuth2Authorizations.java
  2. 48 0
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeActorTests.java
  3. 702 0
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeAuthenticationProviderTests.java
  4. 134 0
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeAuthenticationTokenTests.java
  5. 66 0
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeCompositeAuthenticationTokenTests.java
  6. 368 0
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2TokenExchangeGrantTests.java
  7. 128 0
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/token/DefaultOAuth2TokenClaimsConsumerTests.java
  8. 84 2
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2TokenEndpointFilterTests.java
  9. 336 0
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2TokenExchangeAuthenticationConverterTests.java

+ 5 - 2
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<String, Object> tokenMetadata(Map<String, Object> tokenClaims) {
+	private static Map<String, Object> tokenMetadata(RegisteredClient registeredClient, Map<String, Object> tokenClaims) {
 		Map<String, Object> 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();

+ 48 - 0
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<String, Object> claims = Map.of("claim1", "value1");
+		OAuth2TokenExchangeActor actor = new OAuth2TokenExchangeActor(claims);
+		assertThat(actor.getClaims()).isEqualTo(claims);
+	}
+
+}

+ 702 - 0
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<String> RESOURCES = Set.of("https://mydomain.com/resource1", "https://mydomain.com/resource2");
+	private static final Set<String> 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<OAuth2Token> 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<String, String> 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<String, Object> 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<String, String> 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<String, Object> 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<String, String> 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<OAuth2Authorization> authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class);
+		ArgumentCaptor<OAuth2TokenContext> 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.<Authentication>getPrincipal()).isSameAs(userPrincipal);
+		assertThat(tokenContext.getAuthorizationServerContext()).isNotNull();
+		assertThat(tokenContext.getAuthorizedScopes()).isEqualTo(authentication.getScopes());
+		assertThat(tokenContext.getTokenType()).isEqualTo(OAuth2TokenType.ACCESS_TOKEN);
+		assertThat(tokenContext.<Authentication>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.<Authentication>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<OAuth2Authorization> authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class);
+		ArgumentCaptor<OAuth2TokenContext> 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.<Authentication>getPrincipal()).isSameAs(userPrincipal);
+		assertThat(tokenContext.getAuthorizationServerContext()).isNotNull();
+		assertThat(tokenContext.getAuthorizedScopes()).isEqualTo(authentication.getScopes());
+		assertThat(tokenContext.getTokenType()).isEqualTo(OAuth2TokenType.ACCESS_TOKEN);
+		assertThat(tokenContext.<Authentication>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.<Authentication>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<OAuth2Authorization> authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class);
+		ArgumentCaptor<OAuth2TokenContext> 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.<Authentication>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<String> 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<Map<String, Object>> withClaims(Map<String, Object> claims) {
+		return (metadata) -> metadata.put(OAuth2Authorization.Token.CLAIMS_METADATA_NAME, claims);
+	}
+
+	private static Consumer<Map<String, Object>> withTokenFormat(OAuth2TokenFormat tokenFormat) {
+		return (metadata) -> metadata.put(OAuth2TokenFormat.class.getName(), tokenFormat.getValue());
+	}
+
+}

+ 134 - 0
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<String> RESOURCES = Set.of("https://mydomain.com/resource1", "https://mydomain.com/resource2");
+	private static final Set<String> 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<String> scopes = Collections.singleton("scope1");
+	private Map<String, Object> 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);
+	}
+
+}

+ 66 - 0
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<OAuth2TokenExchangeActor> actors = List.of(actor1, actor2);
+		OAuth2TokenExchangeCompositeAuthenticationToken authentication =
+				new OAuth2TokenExchangeCompositeAuthenticationToken(subject, actors);
+		assertThat(authentication.getSubject()).isEqualTo(subject);
+		assertThat(authentication.getActors()).isEqualTo(actors);
+	}
+
+}

+ 368 - 0
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<OAuth2AccessTokenResponse> 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<String, String> 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<String, String> 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.<Authentication>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<String, Object> actorTokenClaims = new HashMap<>();
+		actorTokenClaims.put(OAuth2TokenClaimNames.ISS, "issuer2");
+		actorTokenClaims.put(OAuth2TokenClaimNames.SUB, "admin");
+		Map<String, Object> 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<String, String> 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.<Authentication>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<Map<String, Object>> withInvalidated() {
+		return (metadata) -> metadata.put(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME, true);
+	}
+
+	private static Function<OAuth2Authorization.Token<? extends OAuth2Token>, Boolean> isInvalidated() {
+		return (token) -> token.getMetadata(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME);
+	}
+
+	@EnableWebSecurity
+	@Import(OAuth2AuthorizationServerConfiguration.class)
+	static class AuthorizationServerConfiguration {
+
+		static JWKSource<SecurityContext> 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<SecurityContext> jwkSource() {
+			return JWK_SOURCE;
+		}
+
+		@Bean
+		PasswordEncoder passwordEncoder() {
+			return NoOpPasswordEncoder.getInstance();
+		}
+	}
+
+}

+ 128 - 0
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<Map<String, Object>> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> claims = new LinkedHashMap<>();
+		this.consumer.accept(claims);
+		assertThat(claims).hasSize(1);
+		assertThat(claims.get("act")).isNotNull();
+		@SuppressWarnings("unchecked")
+		Map<String, Object> actClaim1 = (Map<String, Object>) claims.get("act");
+		assertThat(actClaim1.get(OAuth2TokenClaimNames.ISS)).isEqualTo("issuer1");
+		assertThat(actClaim1.get(OAuth2TokenClaimNames.SUB)).isEqualTo("actor1");
+		@SuppressWarnings("unchecked")
+		Map<String, Object> actClaim2 = (Map<String, Object>) actClaim1.get("act");
+		assertThat(actClaim2.get(OAuth2TokenClaimNames.ISS)).isEqualTo("issuer2");
+		assertThat(actClaim2.get(OAuth2TokenClaimNames.SUB)).isEqualTo("actor2");
+	}
+
+}

+ 84 - 2
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<OAuth2Error> 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<OAuth2TokenExchangeAuthenticationToken> 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;
+	}
 }

+ 336 - 0
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;
+	}
+
+}