From 2ec9329b862b29b9f9fe73c2da66df7a4afe1358 Mon Sep 17 00:00:00 2001 From: Steve Riesenberg <5248162+sjohnr@users.noreply.github.com> Date: Mon, 12 Feb 2024 15:34:54 -0600 Subject: [PATCH] Add Token Exchange grant to demo-client sample Issue gh-60 --- .../config/AuthorizationServerConfig.java | 10 ++ .../web/AuthorizationConsentController.java | 4 + .../sample/web/AuthorizationController.java | 22 +++- .../src/main/resources/application.yml | 11 ++ .../resources/templates/page-templates.html | 1 + .../samples-users-resource.gradle | 21 +++ .../java/sample/UsersResourceApplication.java | 32 +++++ ...faultTokenExchangeTokenResponseClient.java | 85 ++++++++++++ .../TokenExchangeGrantRequest.java | 54 ++++++++ ...enExchangeGrantRequestEntityConverter.java | 78 +++++++++++ ...xchangeOAuth2AuthorizedClientProvider.java | 121 ++++++++++++++++++ .../java/sample/config/SecurityConfig.java | 50 ++++++++ .../sample/config/TokenExchangeConfig.java | 104 +++++++++++++++ .../main/java/sample/web/UserController.java | 68 ++++++++++ .../src/main/resources/application.yml | 34 +++++ 15 files changed, 693 insertions(+), 2 deletions(-) create mode 100644 samples/users-resource/samples-users-resource.gradle create mode 100644 samples/users-resource/src/main/java/sample/UsersResourceApplication.java create mode 100644 samples/users-resource/src/main/java/sample/authorization/DefaultTokenExchangeTokenResponseClient.java create mode 100644 samples/users-resource/src/main/java/sample/authorization/TokenExchangeGrantRequest.java create mode 100644 samples/users-resource/src/main/java/sample/authorization/TokenExchangeGrantRequestEntityConverter.java create mode 100644 samples/users-resource/src/main/java/sample/authorization/TokenExchangeOAuth2AuthorizedClientProvider.java create mode 100644 samples/users-resource/src/main/java/sample/config/SecurityConfig.java create mode 100644 samples/users-resource/src/main/java/sample/config/TokenExchangeConfig.java create mode 100644 samples/users-resource/src/main/java/sample/web/UserController.java create mode 100644 samples/users-resource/src/main/resources/application.yml diff --git a/samples/demo-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java b/samples/demo-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java index b868e234..0746516e 100644 --- a/samples/demo-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java +++ b/samples/demo-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java @@ -145,6 +145,7 @@ public class AuthorizationServerConfig { .scope(OidcScopes.PROFILE) .scope("message.read") .scope("message.write") + .scope("user.read") .clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build()) .build(); @@ -157,10 +158,19 @@ public class AuthorizationServerConfig { .scope("message.write") .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 JdbcRegisteredClientRepository registeredClientRepository = new JdbcRegisteredClientRepository(jdbcTemplate); registeredClientRepository.save(registeredClient); registeredClientRepository.save(deviceClient); + registeredClientRepository.save(tokenExchangeClient); return registeredClientRepository; } diff --git a/samples/demo-authorizationserver/src/main/java/sample/web/AuthorizationConsentController.java b/samples/demo-authorizationserver/src/main/java/sample/web/AuthorizationConsentController.java index c21e6e87..f166f309 100644 --- a/samples/demo-authorizationserver/src/main/java/sample/web/AuthorizationConsentController.java +++ b/samples/demo-authorizationserver/src/main/java/sample/web/AuthorizationConsentController.java @@ -118,6 +118,10 @@ public class AuthorizationConsentController { "message.write", "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( "other.scope", "This is another scope example of a scope description." diff --git a/samples/demo-client/src/main/java/sample/web/AuthorizationController.java b/samples/demo-client/src/main/java/sample/web/AuthorizationController.java index c92d7896..ad9b454a 100644 --- a/samples/demo-client/src/main/java/sample/web/AuthorizationController.java +++ b/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"); * 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 { private final WebClient webClient; private final String messagesBaseUri; + private final String userMessagesBaseUri; 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.messagesBaseUri = messagesBaseUri; + this.userMessagesBaseUri = userMessagesBaseUri; } @GetMapping(value = "/authorize", params = "grant_type=authorization_code") @@ -96,6 +99,21 @@ public class AuthorizationController { 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") public String deviceCodeGrant() { return "device-activate"; diff --git a/samples/demo-client/src/main/resources/application.yml b/samples/demo-client/src/main/resources/application.yml index f39ef426..18e63127 100644 --- a/samples/demo-client/src/main/resources/application.yml +++ b/samples/demo-client/src/main/resources/application.yml @@ -38,6 +38,14 @@ spring: authorization-grant-type: client_credentials scope: message.read,message.write 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: provider: spring client-id: device-messaging-client @@ -51,3 +59,6 @@ spring: messages: base-uri: http://127.0.0.1:8090/messages + +user-messages: + base-uri: http://127.0.0.1:8091/user/messages diff --git a/samples/demo-client/src/main/resources/templates/page-templates.html b/samples/demo-client/src/main/resources/templates/page-templates.html index 6d9e17be..1704bdee 100644 --- a/samples/demo-client/src/main/resources/templates/page-templates.html +++ b/samples/demo-client/src/main/resources/templates/page-templates.html @@ -25,6 +25,7 @@ diff --git a/samples/users-resource/samples-users-resource.gradle b/samples/users-resource/samples-users-resource.gradle new file mode 100644 index 00000000..7c2fbd44 --- /dev/null +++ b/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" +} diff --git a/samples/users-resource/src/main/java/sample/UsersResourceApplication.java b/samples/users-resource/src/main/java/sample/UsersResourceApplication.java new file mode 100644 index 00000000..5195362c --- /dev/null +++ b/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); + } + +} diff --git a/samples/users-resource/src/main/java/sample/authorization/DefaultTokenExchangeTokenResponseClient.java b/samples/users-resource/src/main/java/sample/authorization/DefaultTokenExchangeTokenResponseClient.java new file mode 100644 index 00000000..86e2b780 --- /dev/null +++ b/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 { + + private static final String INVALID_TOKEN_RESPONSE_ERROR_CODE = "invalid_token_response"; + + private Converter> 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 responseEntity = getResponse(requestEntity); + + return responseEntity.getBody(); + } + + private ResponseEntity 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> 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; + } + +} diff --git a/samples/users-resource/src/main/java/sample/authorization/TokenExchangeGrantRequest.java b/samples/users-resource/src/main/java/sample/authorization/TokenExchangeGrantRequest.java new file mode 100644 index 00000000..fb52fb67 --- /dev/null +++ b/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; + } +} \ No newline at end of file diff --git a/samples/users-resource/src/main/java/sample/authorization/TokenExchangeGrantRequestEntityConverter.java b/samples/users-resource/src/main/java/sample/authorization/TokenExchangeGrantRequestEntityConverter.java new file mode 100644 index 00000000..ba3fbfc5 --- /dev/null +++ b/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> { + + 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 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); + } + +} diff --git a/samples/users-resource/src/main/java/sample/authorization/TokenExchangeOAuth2AuthorizedClientProvider.java b/samples/users-resource/src/main/java/sample/authorization/TokenExchangeOAuth2AuthorizedClientProvider.java new file mode 100644 index 00000000..4bfd94da --- /dev/null +++ b/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 accessTokenResponseClient = new DefaultTokenExchangeTokenResponseClient(); + + private Function subjectTokenResolver = this::resolveSubjectToken; + + private Function 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 accessTokenResponseClient) { + Assert.notNull(accessTokenResponseClient, "accessTokenResponseClient cannot be null"); + this.accessTokenResponseClient = accessTokenResponseClient; + } + + public void setSubjectTokenResolver(Function subjectTokenResolver) { + Assert.notNull(subjectTokenResolver, "subjectTokenResolver cannot be null"); + this.subjectTokenResolver = subjectTokenResolver; + } + + public void setActorTokenResolver(Function 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; + } + +} diff --git a/samples/users-resource/src/main/java/sample/config/SecurityConfig.java b/samples/users-resource/src/main/java/sample/config/SecurityConfig.java new file mode 100644 index 00000000..4126eba8 --- /dev/null +++ b/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(); + } + +} diff --git a/samples/users-resource/src/main/java/sample/config/TokenExchangeConfig.java b/samples/users-resource/src/main/java/sample/config/TokenExchangeConfig.java new file mode 100644 index 00000000..c454df0d --- /dev/null +++ b/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 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 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(); + }; + } + +} diff --git a/samples/users-resource/src/main/java/sample/web/UserController.java b/samples/users-resource/src/main/java/sample/web/UserController.java new file mode 100644 index 00000000..20a654b8 --- /dev/null +++ b/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 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 userMessages = new ArrayList<>(Arrays.asList(messages)); + userMessages.add("%s has %d unread messages".formatted(jwt.getSubject(), messages.length)); + + return userMessages; + } + +} diff --git a/samples/users-resource/src/main/resources/application.yml b/samples/users-resource/src/main/resources/application.yml new file mode 100644 index 00000000..71acccef --- /dev/null +++ b/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