|
1 | 1 | /*
|
2 |
| - * Copyright 2020-2022 the original author or authors. |
| 2 | + * Copyright 2020-2024 the original author or authors. |
3 | 3 | *
|
4 | 4 | * Licensed under the Apache License, Version 2.0 (the "License");
|
5 | 5 | * you may not use this file except in compliance with the License.
|
|
23 | 23 | import java.util.List;
|
24 | 24 | import java.util.Set;
|
25 | 25 |
|
| 26 | +import jakarta.servlet.http.HttpServletRequest; |
| 27 | + |
26 | 28 | import com.nimbusds.jose.jwk.JWKSet;
|
27 | 29 | import com.nimbusds.jose.jwk.source.JWKSource;
|
28 | 30 | import com.nimbusds.jose.proc.SecurityContext;
|
|
34 | 36 |
|
35 | 37 | import org.springframework.beans.factory.annotation.Autowired;
|
36 | 38 | import org.springframework.context.annotation.Bean;
|
| 39 | +import org.springframework.context.annotation.Configuration; |
37 | 40 | import org.springframework.context.annotation.Import;
|
38 | 41 | import org.springframework.http.HttpHeaders;
|
39 | 42 | import org.springframework.http.HttpStatus;
|
|
43 | 46 | import org.springframework.jdbc.datasource.embedded.EmbeddedDatabase;
|
44 | 47 | import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder;
|
45 | 48 | import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType;
|
| 49 | +import org.springframework.lang.Nullable; |
46 | 50 | import org.springframework.mock.http.client.MockClientHttpResponse;
|
47 | 51 | import org.springframework.mock.web.MockHttpServletResponse;
|
| 52 | +import org.springframework.security.authentication.AuthenticationProvider; |
48 | 53 | import org.springframework.security.authentication.TestingAuthenticationToken;
|
| 54 | +import org.springframework.security.config.annotation.web.builders.HttpSecurity; |
49 | 55 | import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
50 | 56 | import org.springframework.security.core.Authentication;
|
| 57 | +import org.springframework.security.core.AuthenticationException; |
51 | 58 | import org.springframework.security.core.GrantedAuthority;
|
| 59 | +import org.springframework.security.core.Transient; |
52 | 60 | import org.springframework.security.crypto.password.NoOpPasswordEncoder;
|
53 | 61 | import org.springframework.security.crypto.password.PasswordEncoder;
|
54 | 62 | import org.springframework.security.oauth2.core.AuthorizationGrantType;
|
| 63 | +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; |
55 | 64 | import org.springframework.security.oauth2.core.OAuth2AccessToken;
|
| 65 | +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; |
| 66 | +import org.springframework.security.oauth2.core.OAuth2Error; |
| 67 | +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; |
56 | 68 | import org.springframework.security.oauth2.core.OAuth2Token;
|
57 | 69 | import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse;
|
58 | 70 | import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
|
|
66 | 78 | import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
|
67 | 79 | import org.springframework.security.oauth2.server.authorization.OAuth2TokenType;
|
68 | 80 | import org.springframework.security.oauth2.server.authorization.TestOAuth2Authorizations;
|
| 81 | +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientAuthenticationToken; |
69 | 82 | import org.springframework.security.oauth2.server.authorization.client.JdbcRegisteredClientRepository;
|
70 | 83 | import org.springframework.security.oauth2.server.authorization.client.JdbcRegisteredClientRepository.RegisteredClientParametersMapper;
|
71 | 84 | import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
|
|
77 | 90 | import org.springframework.security.oauth2.server.authorization.test.SpringTestContextExtension;
|
78 | 91 | import org.springframework.security.oauth2.server.authorization.token.JwtEncodingContext;
|
79 | 92 | import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenCustomizer;
|
| 93 | +import org.springframework.security.web.SecurityFilterChain; |
| 94 | +import org.springframework.security.web.authentication.AuthenticationConverter; |
| 95 | +import org.springframework.security.web.util.matcher.RequestMatcher; |
80 | 96 | import org.springframework.test.web.servlet.MockMvc;
|
81 | 97 | import org.springframework.test.web.servlet.MvcResult;
|
| 98 | +import org.springframework.util.Assert; |
82 | 99 | import org.springframework.util.LinkedMultiValueMap;
|
83 | 100 | import org.springframework.util.MultiValueMap;
|
| 101 | +import org.springframework.util.StringUtils; |
84 | 102 |
|
85 | 103 | import static org.assertj.core.api.Assertions.assertThat;
|
86 | 104 | import static org.hamcrest.CoreMatchers.containsString;
|
@@ -217,6 +235,32 @@ public void requestWhenRevokeAndRefreshThenAccessTokenActive() throws Exception
|
217 | 235 | assertThat(accessToken.isActive()).isTrue();
|
218 | 236 | }
|
219 | 237 |
|
| 238 | + // gh-1430 |
| 239 | + @Test |
| 240 | + public void requestWhenRefreshTokenRequestWithPublicClientThenReturnAccessTokenResponse() throws Exception { |
| 241 | + this.spring.register(AuthorizationServerConfigurationWithPublicClientAuthentication.class).autowire(); |
| 242 | + |
| 243 | + RegisteredClient registeredClient = TestRegisteredClients.registeredPublicClient() |
| 244 | + .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) |
| 245 | + .build(); |
| 246 | + this.registeredClientRepository.save(registeredClient); |
| 247 | + |
| 248 | + OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient).build(); |
| 249 | + this.authorizationService.save(authorization); |
| 250 | + |
| 251 | + this.mvc.perform(post(DEFAULT_TOKEN_ENDPOINT_URI) |
| 252 | + .params(getRefreshTokenRequestParameters(authorization)) |
| 253 | + .param(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId())) |
| 254 | + .andExpect(status().isOk()) |
| 255 | + .andExpect(header().string(HttpHeaders.CACHE_CONTROL, containsString("no-store"))) |
| 256 | + .andExpect(header().string(HttpHeaders.PRAGMA, containsString("no-cache"))) |
| 257 | + .andExpect(jsonPath("$.access_token").isNotEmpty()) |
| 258 | + .andExpect(jsonPath("$.token_type").isNotEmpty()) |
| 259 | + .andExpect(jsonPath("$.expires_in").isNotEmpty()) |
| 260 | + .andExpect(jsonPath("$.refresh_token").isNotEmpty()) |
| 261 | + .andExpect(jsonPath("$.scope").isNotEmpty()); |
| 262 | + } |
| 263 | + |
220 | 264 | private static MultiValueMap<String, String> getRefreshTokenRequestParameters(OAuth2Authorization authorization) {
|
221 | 265 | MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
|
222 | 266 | parameters.set(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.REFRESH_TOKEN.getValue());
|
@@ -307,4 +351,119 @@ static class ParametersMapper extends JdbcOAuth2AuthorizationService.OAuth2Autho
|
307 | 351 | }
|
308 | 352 |
|
309 | 353 | }
|
| 354 | + |
| 355 | + @EnableWebSecurity |
| 356 | + @Configuration(proxyBeanMethods = false) |
| 357 | + static class AuthorizationServerConfigurationWithPublicClientAuthentication extends AuthorizationServerConfiguration { |
| 358 | + // @formatter:off |
| 359 | + @Bean |
| 360 | + SecurityFilterChain authorizationServerSecurityFilterChain( |
| 361 | + HttpSecurity http, RegisteredClientRepository registeredClientRepository) throws Exception { |
| 362 | + |
| 363 | + OAuth2AuthorizationServerConfigurer authorizationServerConfigurer = |
| 364 | + new OAuth2AuthorizationServerConfigurer(); |
| 365 | + authorizationServerConfigurer |
| 366 | + .clientAuthentication(clientAuthentication -> |
| 367 | + clientAuthentication |
| 368 | + .authenticationConverter( |
| 369 | + new PublicClientRefreshTokenAuthenticationConverter()) |
| 370 | + .authenticationProvider( |
| 371 | + new PublicClientRefreshTokenAuthenticationProvider(registeredClientRepository)) |
| 372 | + ); |
| 373 | + RequestMatcher endpointsMatcher = authorizationServerConfigurer.getEndpointsMatcher(); |
| 374 | + |
| 375 | + http |
| 376 | + .securityMatcher(endpointsMatcher) |
| 377 | + .authorizeHttpRequests(authorize -> |
| 378 | + authorize.anyRequest().authenticated() |
| 379 | + ) |
| 380 | + .csrf(csrf -> csrf.ignoringRequestMatchers(endpointsMatcher)) |
| 381 | + .apply(authorizationServerConfigurer); |
| 382 | + return http.build(); |
| 383 | + } |
| 384 | + // @formatter:on |
| 385 | + } |
| 386 | + |
| 387 | + @Transient |
| 388 | + private static final class PublicClientRefreshTokenAuthenticationToken extends OAuth2ClientAuthenticationToken { |
| 389 | + |
| 390 | + private PublicClientRefreshTokenAuthenticationToken(String clientId) { |
| 391 | + super(clientId, ClientAuthenticationMethod.NONE, null, null); |
| 392 | + } |
| 393 | + |
| 394 | + private PublicClientRefreshTokenAuthenticationToken(RegisteredClient registeredClient) { |
| 395 | + super(registeredClient, ClientAuthenticationMethod.NONE, null); |
| 396 | + } |
| 397 | + |
| 398 | + } |
| 399 | + |
| 400 | + private static final class PublicClientRefreshTokenAuthenticationConverter implements AuthenticationConverter { |
| 401 | + |
| 402 | + @Nullable |
| 403 | + @Override |
| 404 | + public Authentication convert(HttpServletRequest request) { |
| 405 | + // grant_type (REQUIRED) |
| 406 | + String grantType = request.getParameter(OAuth2ParameterNames.GRANT_TYPE); |
| 407 | + if (!AuthorizationGrantType.REFRESH_TOKEN.getValue().equals(grantType)) { |
| 408 | + return null; |
| 409 | + } |
| 410 | + |
| 411 | + // client_id (REQUIRED) |
| 412 | + String clientId = request.getParameter(OAuth2ParameterNames.CLIENT_ID); |
| 413 | + if (!StringUtils.hasText(clientId)) { |
| 414 | + return null; |
| 415 | + } |
| 416 | + |
| 417 | + return new PublicClientRefreshTokenAuthenticationToken(clientId); |
| 418 | + } |
| 419 | + |
| 420 | + } |
| 421 | + |
| 422 | + private static final class PublicClientRefreshTokenAuthenticationProvider implements AuthenticationProvider { |
| 423 | + private final RegisteredClientRepository registeredClientRepository; |
| 424 | + |
| 425 | + private PublicClientRefreshTokenAuthenticationProvider(RegisteredClientRepository registeredClientRepository) { |
| 426 | + Assert.notNull(registeredClientRepository, "registeredClientRepository cannot be null"); |
| 427 | + this.registeredClientRepository = registeredClientRepository; |
| 428 | + } |
| 429 | + |
| 430 | + @Override |
| 431 | + public Authentication authenticate(Authentication authentication) throws AuthenticationException { |
| 432 | + PublicClientRefreshTokenAuthenticationToken publicClientAuthentication = |
| 433 | + (PublicClientRefreshTokenAuthenticationToken) authentication; |
| 434 | + |
| 435 | + if (!ClientAuthenticationMethod.NONE.equals(publicClientAuthentication.getClientAuthenticationMethod())) { |
| 436 | + return null; |
| 437 | + } |
| 438 | + |
| 439 | + String clientId = publicClientAuthentication.getPrincipal().toString(); |
| 440 | + RegisteredClient registeredClient = this.registeredClientRepository.findByClientId(clientId); |
| 441 | + if (registeredClient == null) { |
| 442 | + throwInvalidClient(OAuth2ParameterNames.CLIENT_ID); |
| 443 | + } |
| 444 | + |
| 445 | + if (!registeredClient.getClientAuthenticationMethods().contains( |
| 446 | + publicClientAuthentication.getClientAuthenticationMethod())) { |
| 447 | + throwInvalidClient("authentication_method"); |
| 448 | + } |
| 449 | + |
| 450 | + return new PublicClientRefreshTokenAuthenticationToken(registeredClient); |
| 451 | + } |
| 452 | + |
| 453 | + @Override |
| 454 | + public boolean supports(Class<?> authentication) { |
| 455 | + return PublicClientRefreshTokenAuthenticationToken.class.isAssignableFrom(authentication); |
| 456 | + } |
| 457 | + |
| 458 | + private static void throwInvalidClient(String parameterName) { |
| 459 | + OAuth2Error error = new OAuth2Error( |
| 460 | + OAuth2ErrorCodes.INVALID_CLIENT, |
| 461 | + "Public client authentication failed: " + parameterName, |
| 462 | + null |
| 463 | + ); |
| 464 | + throw new OAuth2AuthenticationException(error); |
| 465 | + } |
| 466 | + |
| 467 | + } |
| 468 | + |
310 | 469 | }
|
0 commit comments