瀏覽代碼

Add Token Exchange grant to demo-client sample

Issue gh-60
Steve Riesenberg 1 年之前
父節點
當前提交
2ec9329b86

+ 10 - 0
samples/demo-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java

@@ -145,6 +145,7 @@ public class AuthorizationServerConfig {
 				.scope(OidcScopes.PROFILE)
 				.scope(OidcScopes.PROFILE)
 				.scope("message.read")
 				.scope("message.read")
 				.scope("message.write")
 				.scope("message.write")
+				.scope("user.read")
 				.clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build())
 				.clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build())
 				.build();
 				.build();
 
 
@@ -157,10 +158,19 @@ public class AuthorizationServerConfig {
 				.scope("message.write")
 				.scope("message.write")
 				.build();
 				.build();
 
 
+		RegisteredClient tokenExchangeClient = RegisteredClient.withId(UUID.randomUUID().toString())
+				.clientId("token-client")
+				.clientSecret("{noop}token")
+				.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
+				.authorizationGrantType(new AuthorizationGrantType("urn:ietf:params:oauth:grant-type:token-exchange"))
+				.scope("message.read")
+				.build();
+
 		// Save registered client's in db as if in-memory
 		// Save registered client's in db as if in-memory
 		JdbcRegisteredClientRepository registeredClientRepository = new JdbcRegisteredClientRepository(jdbcTemplate);
 		JdbcRegisteredClientRepository registeredClientRepository = new JdbcRegisteredClientRepository(jdbcTemplate);
 		registeredClientRepository.save(registeredClient);
 		registeredClientRepository.save(registeredClient);
 		registeredClientRepository.save(deviceClient);
 		registeredClientRepository.save(deviceClient);
+		registeredClientRepository.save(tokenExchangeClient);
 
 
 		return registeredClientRepository;
 		return registeredClientRepository;
 	}
 	}

+ 4 - 0
samples/demo-authorizationserver/src/main/java/sample/web/AuthorizationConsentController.java

@@ -118,6 +118,10 @@ public class AuthorizationConsentController {
 					"message.write",
 					"message.write",
 					"This application will be able to add new messages. It will also be able to edit and delete existing messages."
 					"This application will be able to add new messages. It will also be able to edit and delete existing messages."
 			);
 			);
+			scopeDescriptions.put(
+					"user.read",
+					"This application will be able to read your user information."
+			);
 			scopeDescriptions.put(
 			scopeDescriptions.put(
 					"other.scope",
 					"other.scope",
 					"This is another scope example of a scope description."
 					"This is another scope example of a scope description."

+ 20 - 2
samples/demo-client/src/main/java/sample/web/AuthorizationController.java

@@ -1,5 +1,5 @@
 /*
 /*
- * Copyright 2020-2023 the original author or authors.
+ * Copyright 2020-2024 the original author or authors.
  *
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
  * you may not use this file except in compliance with the License.
@@ -41,11 +41,14 @@ import static org.springframework.security.oauth2.client.web.reactive.function.c
 public class AuthorizationController {
 public class AuthorizationController {
 	private final WebClient webClient;
 	private final WebClient webClient;
 	private final String messagesBaseUri;
 	private final String messagesBaseUri;
+	private final String userMessagesBaseUri;
 
 
 	public AuthorizationController(WebClient webClient,
 	public AuthorizationController(WebClient webClient,
-			@Value("${messages.base-uri}") String messagesBaseUri) {
+			@Value("${messages.base-uri}") String messagesBaseUri,
+			@Value("${user-messages.base-uri}") String userMessagesBaseUri) {
 		this.webClient = webClient;
 		this.webClient = webClient;
 		this.messagesBaseUri = messagesBaseUri;
 		this.messagesBaseUri = messagesBaseUri;
+		this.userMessagesBaseUri = userMessagesBaseUri;
 	}
 	}
 
 
 	@GetMapping(value = "/authorize", params = "grant_type=authorization_code")
 	@GetMapping(value = "/authorize", params = "grant_type=authorization_code")
@@ -96,6 +99,21 @@ public class AuthorizationController {
 		return "index";
 		return "index";
 	}
 	}
 
 
+	@GetMapping(value = "/authorize", params = "grant_type=token_exchange")
+	public String tokenExchangeGrant(Model model) {
+
+		String[] messages = this.webClient
+				.get()
+				.uri(this.userMessagesBaseUri)
+				.attributes(clientRegistrationId("user-client-authorization-code"))
+				.retrieve()
+				.bodyToMono(String[].class)
+				.block();
+		model.addAttribute("messages", messages);
+
+		return "index";
+	}
+
 	@GetMapping(value = "/authorize", params = "grant_type=device_code")
 	@GetMapping(value = "/authorize", params = "grant_type=device_code")
 	public String deviceCodeGrant() {
 	public String deviceCodeGrant() {
 		return "device-activate";
 		return "device-activate";

+ 11 - 0
samples/demo-client/src/main/resources/application.yml

@@ -38,6 +38,14 @@ spring:
             authorization-grant-type: client_credentials
             authorization-grant-type: client_credentials
             scope: message.read,message.write
             scope: message.read,message.write
             client-name: messaging-client-client-credentials
             client-name: messaging-client-client-credentials
+          user-client-authorization-code:
+            provider: spring
+            client-id: messaging-client
+            client-secret: secret
+            authorization-grant-type: authorization_code
+            redirect-uri: "http://127.0.0.1:8080/authorized"
+            scope: user.read
+            client-name: user-client-authorization-code
           messaging-client-device-code:
           messaging-client-device-code:
             provider: spring
             provider: spring
             client-id: device-messaging-client
             client-id: device-messaging-client
@@ -51,3 +59,6 @@ spring:
 
 
 messages:
 messages:
   base-uri: http://127.0.0.1:8090/messages
   base-uri: http://127.0.0.1:8090/messages
+
+user-messages:
+  base-uri: http://127.0.0.1:8091/user/messages

+ 1 - 0
samples/demo-client/src/main/resources/templates/page-templates.html

@@ -25,6 +25,7 @@
                     <ul class="dropdown-menu">
                     <ul class="dropdown-menu">
                         <li><a class="dropdown-item" href="/authorize?grant_type=authorization_code" th:href="@{/authorize?grant_type=authorization_code}">Authorization Code</a></li>
                         <li><a class="dropdown-item" href="/authorize?grant_type=authorization_code" th:href="@{/authorize?grant_type=authorization_code}">Authorization Code</a></li>
                         <li><a class="dropdown-item" href="/authorize?grant_type=client_credentials" th:href="@{/authorize?grant_type=client_credentials}">Client Credentials</a></li>
                         <li><a class="dropdown-item" href="/authorize?grant_type=client_credentials" th:href="@{/authorize?grant_type=client_credentials}">Client Credentials</a></li>
+                        <li><a class="dropdown-item" href="/authorize?grant_type=token_exchange" th:href="@{/authorize?grant_type=token_exchange}">Token Exchange</a></li>
                         <li><a class="dropdown-item" href="/authorize?grant_type=device_code" th:href="@{/authorize?grant_type=device_code}">Device Code</a></li>
                         <li><a class="dropdown-item" href="/authorize?grant_type=device_code" th:href="@{/authorize?grant_type=device_code}">Device Code</a></li>
                     </ul>
                     </ul>
                 </li>
                 </li>

+ 21 - 0
samples/users-resource/samples-users-resource.gradle

@@ -0,0 +1,21 @@
+plugins {
+	id "org.springframework.boot" version "3.2.2"
+	id "io.spring.dependency-management" version "1.1.0"
+	id "java"
+}
+
+group = project.rootProject.group
+version = project.rootProject.version
+sourceCompatibility = "17"
+
+repositories {
+	mavenCentral()
+	maven { url "https://repo.spring.io/milestone" }
+}
+
+dependencies {
+	implementation "org.springframework.boot:spring-boot-starter-web"
+	implementation "org.springframework.boot:spring-boot-starter-security"
+	implementation "org.springframework.boot:spring-boot-starter-oauth2-resource-server"
+	implementation "org.springframework.boot:spring-boot-starter-oauth2-client"
+}

+ 32 - 0
samples/users-resource/src/main/java/sample/UsersResourceApplication.java

@@ -0,0 +1,32 @@
+/*
+ * 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 sample;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+/**
+ * @author Steve Riesenberg
+ * @since 1.3
+ */
+@SpringBootApplication
+public class UsersResourceApplication {
+
+	public static void main(String[] args) {
+		SpringApplication.run(UsersResourceApplication.class, args);
+	}
+
+}

+ 85 - 0
samples/users-resource/src/main/java/sample/authorization/DefaultTokenExchangeTokenResponseClient.java

@@ -0,0 +1,85 @@
+/*
+ * 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 sample.authorization;
+
+import java.util.Arrays;
+
+import org.springframework.core.convert.converter.Converter;
+import org.springframework.http.RequestEntity;
+import org.springframework.http.ResponseEntity;
+import org.springframework.http.converter.FormHttpMessageConverter;
+import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient;
+import org.springframework.security.oauth2.client.http.OAuth2ErrorResponseErrorHandler;
+import org.springframework.security.oauth2.core.OAuth2AuthorizationException;
+import org.springframework.security.oauth2.core.OAuth2Error;
+import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse;
+import org.springframework.security.oauth2.core.http.converter.OAuth2AccessTokenResponseHttpMessageConverter;
+import org.springframework.util.Assert;
+import org.springframework.web.client.RestClientException;
+import org.springframework.web.client.RestOperations;
+import org.springframework.web.client.RestTemplate;
+
+/**
+ * @author Steve Riesenberg
+ * @since 1.3
+ */
+public final class DefaultTokenExchangeTokenResponseClient
+		implements OAuth2AccessTokenResponseClient<TokenExchangeGrantRequest> {
+
+	private static final String INVALID_TOKEN_RESPONSE_ERROR_CODE = "invalid_token_response";
+
+	private Converter<TokenExchangeGrantRequest, RequestEntity<?>> requestEntityConverter = new TokenExchangeGrantRequestEntityConverter();
+
+	private RestOperations restOperations;
+
+	public DefaultTokenExchangeTokenResponseClient() {
+		RestTemplate restTemplate = new RestTemplate(Arrays.asList(new FormHttpMessageConverter(),
+				new OAuth2AccessTokenResponseHttpMessageConverter()));
+		restTemplate.setErrorHandler(new OAuth2ErrorResponseErrorHandler());
+		this.restOperations = restTemplate;
+	}
+
+	@Override
+	public OAuth2AccessTokenResponse getTokenResponse(TokenExchangeGrantRequest tokenExchangeGrantRequest) {
+		Assert.notNull(tokenExchangeGrantRequest, "tokenExchangeGrantRequest cannot be null");
+		RequestEntity<?> requestEntity = this.requestEntityConverter.convert(tokenExchangeGrantRequest);
+		ResponseEntity<OAuth2AccessTokenResponse> responseEntity = getResponse(requestEntity);
+
+		return responseEntity.getBody();
+	}
+
+	private ResponseEntity<OAuth2AccessTokenResponse> getResponse(RequestEntity<?> requestEntity) {
+		try {
+			return this.restOperations.exchange(requestEntity, OAuth2AccessTokenResponse.class);
+		} catch (RestClientException ex) {
+			OAuth2Error oauth2Error = new OAuth2Error(INVALID_TOKEN_RESPONSE_ERROR_CODE,
+					"An error occurred while attempting to retrieve the OAuth 2.0 Access Token Response: "
+							+ ex.getMessage(), null);
+			throw new OAuth2AuthorizationException(oauth2Error, ex);
+		}
+	}
+
+	public void setRequestEntityConverter(Converter<TokenExchangeGrantRequest, RequestEntity<?>> requestEntityConverter) {
+		Assert.notNull(requestEntityConverter, "requestEntityConverter cannot be null");
+		this.requestEntityConverter = requestEntityConverter;
+	}
+
+	public void setRestOperations(RestOperations restOperations) {
+		Assert.notNull(restOperations, "restOperations cannot be null");
+		this.restOperations = restOperations;
+	}
+
+}

+ 54 - 0
samples/users-resource/src/main/java/sample/authorization/TokenExchangeGrantRequest.java

@@ -0,0 +1,54 @@
+/*
+ * 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 sample.authorization;
+
+import org.springframework.security.oauth2.client.endpoint.AbstractOAuth2AuthorizationGrantRequest;
+import org.springframework.security.oauth2.client.registration.ClientRegistration;
+import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.util.Assert;
+
+/**
+ * @author Steve Riesenberg
+ * @since 1.3
+ */
+public final class TokenExchangeGrantRequest extends AbstractOAuth2AuthorizationGrantRequest {
+
+	static final AuthorizationGrantType TOKEN_EXCHANGE = new AuthorizationGrantType(
+			"urn:ietf:params:oauth:grant-type:token-exchange");
+
+	private final String subjectToken;
+
+	private final String actorToken;
+
+	public TokenExchangeGrantRequest(ClientRegistration clientRegistration, String subjectToken,
+			String actorToken) {
+		super(TOKEN_EXCHANGE, clientRegistration);
+		Assert.hasText(subjectToken, "subjectToken cannot be empty");
+		if (actorToken != null) {
+			Assert.hasText(actorToken, "actorToken cannot be empty");
+		}
+		this.subjectToken = subjectToken;
+		this.actorToken = actorToken;
+	}
+
+	public String getSubjectToken() {
+		return this.subjectToken;
+	}
+
+	public String getActorToken() {
+		return this.actorToken;
+	}
+}

+ 78 - 0
samples/users-resource/src/main/java/sample/authorization/TokenExchangeGrantRequestEntityConverter.java

@@ -0,0 +1,78 @@
+/*
+ * 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 sample.authorization;
+
+import org.springframework.core.convert.converter.Converter;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.RequestEntity;
+import org.springframework.security.oauth2.client.registration.ClientRegistration;
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
+import org.springframework.util.CollectionUtils;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+import org.springframework.util.StringUtils;
+
+/**
+ * @author Steve Riesenberg
+ * @since 1.3
+ */
+public class TokenExchangeGrantRequestEntityConverter implements Converter<TokenExchangeGrantRequest, RequestEntity<?>> {
+
+	private static final String REQUESTED_TOKEN_TYPE = "requested_token_type";
+
+	private static final String SUBJECT_TOKEN = "subject_token";
+
+	private static final String SUBJECT_TOKEN_TYPE = "subject_token_type";
+
+	private static final String ACTOR_TOKEN = "actor_token";
+
+	private static final String ACTOR_TOKEN_TYPE = "actor_token_type";
+
+	private static final String ACCESS_TOKEN_TYPE_VALUE = "urn:ietf:params:oauth:token-type:access_token";
+
+	@Override
+	public RequestEntity<?> convert(TokenExchangeGrantRequest grantRequest) {
+		ClientRegistration clientRegistration = grantRequest.getClientRegistration();
+
+		HttpHeaders headers = new HttpHeaders();
+		if (clientRegistration.getClientAuthenticationMethod().equals(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)) {
+			headers.setBasicAuth(clientRegistration.getClientId(), clientRegistration.getClientSecret());
+		}
+
+		MultiValueMap<String, Object> requestParameters = new LinkedMultiValueMap<>();
+		requestParameters.add(OAuth2ParameterNames.GRANT_TYPE, grantRequest.getGrantType().getValue());
+		requestParameters.add(REQUESTED_TOKEN_TYPE, ACCESS_TOKEN_TYPE_VALUE);
+		requestParameters.add(SUBJECT_TOKEN, grantRequest.getSubjectToken());
+		requestParameters.add(SUBJECT_TOKEN_TYPE, ACCESS_TOKEN_TYPE_VALUE);
+		if (StringUtils.hasText(grantRequest.getActorToken())) {
+			requestParameters.add(ACTOR_TOKEN, grantRequest.getActorToken());
+			requestParameters.add(ACTOR_TOKEN_TYPE, ACCESS_TOKEN_TYPE_VALUE);
+		}
+		if (!CollectionUtils.isEmpty(clientRegistration.getScopes())) {
+			requestParameters.add(OAuth2ParameterNames.SCOPE,
+					StringUtils.collectionToDelimitedString(clientRegistration.getScopes(), " "));
+		}
+		if (clientRegistration.getClientAuthenticationMethod().equals(ClientAuthenticationMethod.CLIENT_SECRET_POST)) {
+			requestParameters.add(OAuth2ParameterNames.CLIENT_ID, clientRegistration.getClientId());
+			requestParameters.add(OAuth2ParameterNames.CLIENT_SECRET, clientRegistration.getClientSecret());
+		}
+
+		String tokenEndpointUri = clientRegistration.getProviderDetails().getTokenUri();
+		return RequestEntity.post(tokenEndpointUri).headers(headers).body(requestParameters);
+	}
+
+}

+ 121 - 0
samples/users-resource/src/main/java/sample/authorization/TokenExchangeOAuth2AuthorizedClientProvider.java

@@ -0,0 +1,121 @@
+/*
+ * 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 sample.authorization;
+
+import java.time.Clock;
+import java.time.Duration;
+import java.util.function.Function;
+
+import org.springframework.security.oauth2.client.ClientAuthorizationException;
+import org.springframework.security.oauth2.client.OAuth2AuthorizationContext;
+import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
+import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProvider;
+import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient;
+import org.springframework.security.oauth2.client.registration.ClientRegistration;
+import org.springframework.security.oauth2.core.OAuth2AuthorizationException;
+import org.springframework.security.oauth2.core.OAuth2Token;
+import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse;
+import org.springframework.util.Assert;
+
+/**
+ * @author Steve Riesenberg
+ * @since 1.3
+ */
+public final class TokenExchangeOAuth2AuthorizedClientProvider implements OAuth2AuthorizedClientProvider {
+
+	private OAuth2AccessTokenResponseClient<TokenExchangeGrantRequest> accessTokenResponseClient = new DefaultTokenExchangeTokenResponseClient();
+
+	private Function<OAuth2AuthorizationContext, String> subjectTokenResolver = this::resolveSubjectToken;
+
+	private Function<OAuth2AuthorizationContext, String> actorTokenResolver = (context) -> null;
+
+	private Duration clockSkew = Duration.ofSeconds(60);
+
+	private Clock clock = Clock.systemUTC();
+
+	@Override
+	public OAuth2AuthorizedClient authorize(OAuth2AuthorizationContext context) {
+		Assert.notNull(context, "context cannot be null");
+		ClientRegistration clientRegistration = context.getClientRegistration();
+		if (!TokenExchangeGrantRequest.TOKEN_EXCHANGE.equals(clientRegistration.getAuthorizationGrantType())) {
+			return null;
+		}
+		OAuth2AuthorizedClient authorizedClient = context.getAuthorizedClient();
+		if (authorizedClient != null && !hasTokenExpired(authorizedClient.getAccessToken())) {
+			// If client is already authorized but access token is NOT expired than no
+			// need for re-authorization
+			return null;
+		}
+		if (authorizedClient != null && authorizedClient.getRefreshToken() != null) {
+			// If client is already authorized but access token is expired and a
+			// refresh token is available, delegate to refresh_token.
+			return null;
+		}
+
+		TokenExchangeGrantRequest grantRequest = new TokenExchangeGrantRequest(clientRegistration,
+				this.subjectTokenResolver.apply(context), this.actorTokenResolver.apply(context));
+		OAuth2AccessTokenResponse tokenResponse = getTokenResponse(clientRegistration, grantRequest);
+
+		return new OAuth2AuthorizedClient(clientRegistration, context.getPrincipal().getName(),
+				tokenResponse.getAccessToken(), tokenResponse.getRefreshToken());
+	}
+
+	private OAuth2AccessTokenResponse getTokenResponse(ClientRegistration clientRegistration,
+			TokenExchangeGrantRequest grantRequest) {
+		try {
+			return this.accessTokenResponseClient.getTokenResponse(grantRequest);
+		} catch (OAuth2AuthorizationException ex) {
+			throw new ClientAuthorizationException(ex.getError(), clientRegistration.getRegistrationId(), ex);
+		}
+	}
+
+	private boolean hasTokenExpired(OAuth2Token token) {
+		return this.clock.instant().isAfter(token.getExpiresAt().minus(this.clockSkew));
+	}
+
+	private String resolveSubjectToken(OAuth2AuthorizationContext context) {
+		if (context.getPrincipal().getPrincipal() instanceof OAuth2Token accessToken) {
+			return accessToken.getTokenValue();
+		}
+		return null;
+	}
+
+	public void setAccessTokenResponseClient(OAuth2AccessTokenResponseClient<TokenExchangeGrantRequest> accessTokenResponseClient) {
+		Assert.notNull(accessTokenResponseClient, "accessTokenResponseClient cannot be null");
+		this.accessTokenResponseClient = accessTokenResponseClient;
+	}
+
+	public void setSubjectTokenResolver(Function<OAuth2AuthorizationContext, String> subjectTokenResolver) {
+		Assert.notNull(subjectTokenResolver, "subjectTokenResolver cannot be null");
+		this.subjectTokenResolver = subjectTokenResolver;
+	}
+
+	public void setActorTokenResolver(Function<OAuth2AuthorizationContext, String> actorTokenResolver) {
+		Assert.notNull(actorTokenResolver, "actorTokenResolver cannot be null");
+		this.actorTokenResolver = actorTokenResolver;
+	}
+
+	public void setClockSkew(Duration clockSkew) {
+		Assert.notNull(clockSkew, "clockSkew cannot be null");
+		this.clockSkew = clockSkew;
+	}
+
+	public void setClock(Clock clock) {
+		Assert.notNull(clock, "clock cannot be null");
+		this.clock = clock;
+	}
+
+}

+ 50 - 0
samples/users-resource/src/main/java/sample/config/SecurityConfig.java

@@ -0,0 +1,50 @@
+/*
+ * 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 sample.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.config.Customizer;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.web.SecurityFilterChain;
+
+/**
+ * @author Steve Riesenberg
+ * @since 1.3
+ */
+@Configuration
+@EnableWebSecurity
+public class SecurityConfig {
+
+	@Bean
+	public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
+		// @formatter:off
+		http
+			.securityMatcher("/user/**")
+			.authorizeHttpRequests((authorize) -> authorize
+				.requestMatchers("/user/**").hasAuthority("SCOPE_user.read")
+			)
+			.oauth2ResourceServer((oauth2ResourceServer) -> oauth2ResourceServer
+				.jwt(Customizer.withDefaults())
+			)
+			.oauth2Client(Customizer.withDefaults());
+		// @formatter:on
+
+		return http.build();
+	}
+
+}

+ 104 - 0
samples/users-resource/src/main/java/sample/config/TokenExchangeConfig.java

@@ -0,0 +1,104 @@
+/*
+ * 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 sample.config;
+
+import java.util.function.Function;
+
+import sample.authorization.TokenExchangeOAuth2AuthorizedClientProvider;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.oauth2.client.AuthorizedClientServiceOAuth2AuthorizedClientManager;
+import org.springframework.security.oauth2.client.OAuth2AuthorizationContext;
+import org.springframework.security.oauth2.client.OAuth2AuthorizeRequest;
+import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
+import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager;
+import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProvider;
+import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProviderBuilder;
+import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
+import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
+import org.springframework.util.Assert;
+
+/**
+ * @author Steve Riesenberg
+ * @since 1.3
+ */
+@Configuration
+public class TokenExchangeConfig {
+
+	private static final String ACTOR_TOKEN_CLIENT_REGISTRATION_ID = "messaging-client-client-credentials";
+
+	@Bean
+	public OAuth2AuthorizedClientProvider tokenExchange(
+			ClientRegistrationRepository clientRegistrationRepository,
+			OAuth2AuthorizedClientService authorizedClientService) {
+
+		OAuth2AuthorizedClientManager authorizedClientManager = tokenExchangeAuthorizedClientManager(
+				clientRegistrationRepository, authorizedClientService);
+		Function<OAuth2AuthorizationContext, String> actorTokenResolver = createTokenResolver(authorizedClientManager,
+				ACTOR_TOKEN_CLIENT_REGISTRATION_ID);
+
+		TokenExchangeOAuth2AuthorizedClientProvider tokenExchangeAuthorizedClientProvider =
+				new TokenExchangeOAuth2AuthorizedClientProvider();
+		tokenExchangeAuthorizedClientProvider.setActorTokenResolver(actorTokenResolver);
+
+		return tokenExchangeAuthorizedClientProvider;
+	}
+
+	/**
+	 * Create a standalone {@link OAuth2AuthorizedClientManager} for resolving the actor token
+	 * using {@code client_credentials}.
+	 */
+	private static OAuth2AuthorizedClientManager tokenExchangeAuthorizedClientManager(
+			ClientRegistrationRepository clientRegistrationRepository,
+			OAuth2AuthorizedClientService authorizedClientService) {
+
+		// @formatter:off
+		OAuth2AuthorizedClientProvider authorizedClientProvider =
+				OAuth2AuthorizedClientProviderBuilder.builder()
+						.clientCredentials()
+						.build();
+		AuthorizedClientServiceOAuth2AuthorizedClientManager authorizedClientManager =
+				new AuthorizedClientServiceOAuth2AuthorizedClientManager(
+						clientRegistrationRepository, authorizedClientService);
+		// @formatter:on
+		authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider);
+
+		return authorizedClientManager;
+	}
+
+	/**
+	 * Create a {@code Function} to resolve a token from the current principal.
+	 */
+	private static Function<OAuth2AuthorizationContext, String> createTokenResolver(
+			OAuth2AuthorizedClientManager authorizedClientManager, String clientRegistrationId) {
+
+		return (context) -> {
+			// @formatter:off
+			OAuth2AuthorizeRequest authorizeRequest =
+					OAuth2AuthorizeRequest.withClientRegistrationId(clientRegistrationId)
+							.principal(context.getPrincipal())
+							.build();
+			// @formatter:on
+
+			OAuth2AuthorizedClient authorizedClient = authorizedClientManager.authorize(authorizeRequest);
+			Assert.notNull(authorizedClient, "authorizedClient cannot be null");
+
+			return authorizedClient.getAccessToken().getTokenValue();
+		};
+	}
+
+}

+ 68 - 0
samples/users-resource/src/main/java/sample/web/UserController.java

@@ -0,0 +1,68 @@
+/*
+ * 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 sample.web;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Objects;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
+import org.springframework.security.oauth2.client.annotation.RegisteredOAuth2AuthorizedClient;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.client.RestClient;
+
+/**
+ * @author Steve Riesenberg
+ * @since 1.3
+ */
+@RestController
+public class UserController {
+
+	private final RestClient restClient;
+
+	public UserController(@Value("${messages.base-uri}") String baseUrl) {
+		this.restClient = RestClient.builder()
+				.baseUrl(baseUrl)
+				.build();
+	}
+
+	@GetMapping("/user/messages")
+	public List<String> getMessages(@AuthenticationPrincipal Jwt jwt,
+			@RegisteredOAuth2AuthorizedClient("messaging-client-token-exchange")
+					OAuth2AuthorizedClient authorizedClient) {
+
+		// @formatter:off
+		String[] messages = Objects.requireNonNull(
+				this.restClient.get()
+						.uri("/messages")
+						.headers((headers) -> headers.setBearerAuth(authorizedClient.getAccessToken().getTokenValue()))
+						.retrieve()
+						.body(String[].class)
+		);
+		// @formatter:on
+
+		List<String> userMessages = new ArrayList<>(Arrays.asList(messages));
+		userMessages.add("%s has %d unread messages".formatted(jwt.getSubject(), messages.length));
+
+		return userMessages;
+	}
+
+}

+ 34 - 0
samples/users-resource/src/main/resources/application.yml

@@ -0,0 +1,34 @@
+server:
+  port: 8091
+
+logging:
+  level:
+    org.springframework.security: INFO
+
+spring:
+  security:
+    oauth2:
+      resourceserver:
+        jwt:
+          issuer-uri: http://localhost:9000
+      client:
+        registration:
+          messaging-client-client-credentials:
+            provider: spring
+            client-id: messaging-client
+            client-secret: secret
+            authorization-grant-type: client_credentials
+            client-name: messaging-client-client-credentials
+          messaging-client-token-exchange:
+            provider: spring
+            client-id: token-client
+            client-secret: token
+            authorization-grant-type: urn:ietf:params:oauth:grant-type:token-exchange
+            scope: message.read
+            client-name: messaging-client-token-exchange
+        provider:
+          spring:
+            issuer-uri: http://localhost:9000
+
+messages:
+  base-uri: http://127.0.0.1:8090