Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Client id inclusion for refresh token grant is not consistent between servlet and reactive stacks #14811

Closed
Tracked by #15299
benba opened this issue Mar 27, 2024 · 10 comments
Assignees
Labels
in: oauth2 An issue in OAuth2 modules (oauth2-core, oauth2-client, oauth2-resource-server, oauth2-jose) status: duplicate A duplicate of another issue type: enhancement A general enhancement
Milestone

Comments

@benba
Copy link
Contributor

benba commented Mar 27, 2024

When a refresh token grant exchange occurs with a ClientAuthentication Method set to NONE

On a servlet appliction, the client_id field will be missing because it is only added if the ClientAuthenticationMethod is set to CLIENT_SECRET_POST: see OAuth2Refresh TokenGrantRequestEntityConverter

On a reactive applicatoin, the behavior is not consistent because the client_id field is always added except if ClientAuthenticationMethod is set to CLIENT_SECRET_BASIC: see AbstractWebClientReactiveOAuth2AccessTokenResponseClient

Our internal IDP use only public clients but client_id parameter is mandatory, so the reactive default seems better for us, but maybe there are other cases that would make the current servlet default better?
I don't see any clear answer for the specification, some implementations (like Auth0 or Curity) seems to always ask a client_id, but other don't (like Okta).

Maybe the client_id inclusion should depend from another property of the client registration instead of the ClientAuthenticationMethod ?

@benba benba added status: waiting-for-triage An issue we've not yet triaged type: bug A general bug labels Mar 27, 2024
@sjohnr
Copy link
Member

sjohnr commented Apr 18, 2024

@benba thanks for reaching out!

This is a fairly nuanced topic, particularly because (as you pointed out) the specification doesn't paint a perfectly clear picture. A reading of the core OAuth 2.0 spec suggests that whether the client_id parameter is required or not depends entirely on the client authentication mechanism chosen. In all of the examples you provided, the client_id is included when using client_secret_post as the method. It's worth mentioning that the spec discourages its use:

Including the client credentials in the request-body using the two
parameters is NOT RECOMMENDED and SHOULD be limited to clients unable
to directly utilize the HTTP Basic authentication scheme

I'm not clear why Auth0 and Curity would use it as their default example, but I'm sure they both support client_secret_basic as well and don't require client_id in that case. Perhaps I'm wrong though.

In any case, I think this issue extends beyond just refresh tokens to other grant types as well. I believe the reactive side has it mostly correct. Of the currently supported client authentication methods (none, client_secret_basic, client_secret_post, private_key_jwt and client_secret_jwt), it seems to me that only client_secret_basic provides the client id without the need for the parameter. On the servlet side, the authorization code grant is already consistent with this, but the others seem not to be.

Before we go any farther, we would need to identify whether making a change here would be a breaking change and have an impact on users. It doesn't seem likely that clients would break if they start providing a client_id in cases where they were not before. However, changing this type of behavior often causes a lot of disruption for users because of the wide variety of providers. I'm not sure if we can determine how disruptive it will be, and I'd prefer not to move forward without more discussion and feedback on that.

In the meantime, you can of course work around the issue by customizing the token request parameters with the following configuration:

@Bean
public OAuth2AccessTokenResponseClient<OAuth2RefreshTokenGrantRequest> refreshTokenAccessTokenResponseClient() {
	var requestEntityConverter = new OAuth2RefreshTokenGrantRequestEntityConverter();
	requestEntityConverter.addParametersConverter(parametersConverter());

	var accessTokenResponseClient = new DefaultRefreshTokenTokenResponseClient();
	accessTokenResponseClient.setRequestEntityConverter(requestEntityConverter);

	return accessTokenResponseClient;
}

private static Converter<OAuth2RefreshTokenGrantRequest, MultiValueMap<String, String>> parametersConverter() {
	return (grantRequest) -> {
		var clientRegistration = grantRequest.getClientRegistration();
		var clientAuthenticationMethod = clientRegistration.getClientAuthenticationMethod();
		var parameters = new LinkedMultiValueMap<String, String>();
		if (!ClientAuthenticationMethod.CLIENT_SECRET_BASIC.equals(clientAuthenticationMethod)) {
			parameters.set(OAuth2ParameterNames.CLIENT_ID, clientRegistration.getClientId());
		}

		return parameters;
	};
}

I'll spend some time thinking about this, but let me know if you have any additional thoughts.

@marbon87
Copy link
Contributor

I had the same problem by using oauth2-login with a public-pkce client and keycloak as idp.

Thanks for the workaround @sjohnr

@sjohnr sjohnr added in: oauth2 An issue in OAuth2 modules (oauth2-core, oauth2-client, oauth2-resource-server, oauth2-jose) status: blocked An issue that's blocked on an external project change and removed status: waiting-for-triage An issue we've not yet triaged labels Apr 19, 2024
sjohnr added a commit to sjohnr/spring-security that referenced this issue May 1, 2024
sjohnr added a commit to sjohnr/spring-security that referenced this issue May 21, 2024
sjohnr added a commit to sjohnr/spring-security that referenced this issue Jul 1, 2024
This commit refactors and aligns usage of the parametersConverter in
AbstractWebClientReactiveOAuth2AccessTokenResponseClient with the same
capability in AbstractOAuth2AuthorizationGrantRequestEntityConverter
in order to align Reactive with Servlet for better consistency.

Closes spring-projectsgh-14811
@sjohnr
Copy link
Member

sjohnr commented Jul 1, 2024

Apologies for the noise on this issue, I mixed up this issue # with gh-11298.

By way of an update, my goal is to partially address this issue via work on theme issue gh-15299. However, due to needing to remain backwards compatible, I don't anticipate directly addressing this issue with changes to the existing DefaultRefreshTokenTokenResponseClient (and corresponding OAuth2RefreshTokenGrantRequestEntityConverter). Instead, we can make it possible to customize behavior across both Servlet and Reactive similar to the above workaround.

@marbon87
Copy link
Contributor

marbon87 commented Aug 26, 2024

Hi @sjohnr,
the workaround is not working as expected for client-authentication-method=POST, because the client_id-Parameter is added twice (by the custom parametersConverter and in OAuth2RefreshTokenGrantRequestEntityConverter#createParameters) and then you get the following exception:

Caused by: org.springframework.security.oauth2.core.OAuth2AuthorizationException: [invalid_request] duplicated parameter
	at org.springframework.security.oauth2.client.http.OAuth2ErrorResponseErrorHandler.handleError(OAuth2ErrorResponseErrorHandler.java:66)
	at org.springframework.web.client.ResponseErrorHandler.handleError(ResponseErrorHandler.java:63)
	at org.springframework.web.client.RestTemplate.handleResponse(RestTemplate.java:942)
	at org.springframework.web.client.RestTemplate.doExecute(RestTemplate.java:891)
	at org.springframework.web.client.RestTemplate.exchange(RestTemplate.java:730)
	at org.springframework.security.oauth2.client.endpoint.DefaultRefreshTokenTokenResponseClient.getResponse(DefaultRefreshTokenTokenResponseClient.java:98)
	at org.springframework.security.oauth2.client.endpoint.DefaultRefreshTokenTokenResponseClient.getTokenResponse(DefaultRefreshTokenTokenResponseClient.java:74)
	at org.springframework.security.oauth2.client.endpoint.DefaultRefreshTokenTokenResponseClient.getTokenResponse(DefaultRefreshTokenTokenResponseClient.java:53)
	at org.springframework.security.oauth2.client.RefreshTokenOAuth2AuthorizedClientProvider.getTokenResponse(RefreshTokenOAuth2AuthorizedClientProvider.java:101)

IMHO it has to be

private static Converter<OAuth2RefreshTokenGrantRequest, MultiValueMap<String, String>> parametersConverter() {
	return grantRequest -> {
		var clientRegistration = grantRequest.getClientRegistration();
		var clientAuthenticationMethod = clientRegistration.getClientAuthenticationMethod();
		var parameters = new LinkedMultiValueMap<String, String>();
		if (ClientAuthenticationMethod.NONE.equals(clientAuthenticationMethod)) {
			parameters.set(OAuth2ParameterNames.CLIENT_ID, clientRegistration.getClientId());
		}

		return parameters;
	};
}

@sjohnr
Copy link
Member

sjohnr commented Aug 26, 2024

Thanks for the update @marbon87. I think that makes sense for the workaround.

@sjohnr
Copy link
Member

sjohnr commented Sep 10, 2024

Hi everyone. I have merged gh-15337 which includes new implementations of OAuth2AccessTokenResponseClient for servlet applications, which are implemented much more consistently with the reactive counterparts. The goal is to make it possible to opt-in (instead of breaking changes) for refresh token grant (and other grant types) sending parameters in token requests in a consistent manner.

Please try out the changes in 6.4.0-SNAPSHOT and share your feedback related to this issue. If you don't have any other OAuth2 Client customizations, you can opt-in for the refresh token grant by simply publishing the following bean:

@Configuration
@EnableWebSecurity
public class SecurityConfiguration {

	// ...

	@Bean
	public OAuth2AccessTokenResponseClient<OAuth2RefreshTokenGrantRequest> refreshTokenAccessTokenResponseClient() {
		return new RestClientRefreshTokenTokenResponseClient();
	}

}

@sjohnr sjohnr removed the status: blocked An issue that's blocked on an external project change label Sep 10, 2024
@sjohnr sjohnr moved this to In Progress in Spring Security Team Sep 10, 2024
@sjohnr sjohnr added this to the 6.4.x milestone Sep 10, 2024
@marbon87
Copy link
Contributor

Hey @sjohnr,

sorry, but how does this solve the root problem of this issue, that the client-id is missing in the refresh request?
There is stil a difference in reactive and servlet implementation, or not?

@sjohnr
Copy link
Member

sjohnr commented Sep 16, 2024

Hi @marbon87,

As mentioned in this comment, we cannot adjust the behavior in the DefaultRefreshTokenTokenResponseClient as it would not be backwards compatible. However, the new implementation RestClientRefreshTokenTokenResponseClient behaves the same as the reactive implementation. So if you'd like servlet to be consistent, you can opt-in as demonstrated in my previous comment. Does that make sense?

@marbon87
Copy link
Contributor

marbon87 commented Sep 17, 2024

Hey @sjohnr ,

sorry my unit-test had a smell. After i fixed that, everything is working fine with the new RestClientRefreshTokenTokenResponseClient

@sjohnr sjohnr added type: enhancement A general enhancement and removed type: bug A general bug labels Sep 18, 2024
@sjohnr sjohnr moved this from In Progress to Done in Spring Security Team Sep 18, 2024
@sjohnr
Copy link
Member

sjohnr commented Sep 18, 2024

Thanks for the feedback @marbon87.

As mentioned in this comment, directly addressing this issue would break passivity. Instead, users on the servlet stack can opt-in to RestClient-based implementations that are consistent with reactive. I'm going to close this issue as a duplicate of (addressed by) gh-15298.

@sjohnr sjohnr closed this as completed Sep 18, 2024
@sjohnr sjohnr added the status: duplicate A duplicate of another issue label Sep 18, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
in: oauth2 An issue in OAuth2 modules (oauth2-core, oauth2-client, oauth2-resource-server, oauth2-jose) status: duplicate A duplicate of another issue type: enhancement A general enhancement
Projects
Status: Done
Development

No branches or pull requests

3 participants