This is an automated email from the ASF dual-hosted git repository. coheigea pushed a commit to branch 3.6.x-fixes in repository https://gitbox.apache.org/repos/asf/cxf.git
commit 05b031246d79c5500c8879f6fa282eeb0232dcd0 Author: Colm O hEigeartaigh <[email protected]> AuthorDate: Wed Jul 29 11:24:18 2026 +0100 Make sure the refresh token client matches that of the authenticated client (#3346) (cherry picked from commit c95fb6366930ce16c32481f230a5afa89489bd25) --- .../oauth2/provider/AbstractOAuthDataProvider.java | 3 ++ .../provider/AbstractOAuthDataProviderTest.java | 32 ++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/rt/rs/security/oauth-parent/oauth2/src/main/java/org/apache/cxf/rs/security/oauth2/provider/AbstractOAuthDataProvider.java b/rt/rs/security/oauth-parent/oauth2/src/main/java/org/apache/cxf/rs/security/oauth2/provider/AbstractOAuthDataProvider.java index 4ee4d22eda6..94ba4ae9f74 100644 --- a/rt/rs/security/oauth-parent/oauth2/src/main/java/org/apache/cxf/rs/security/oauth2/provider/AbstractOAuthDataProvider.java +++ b/rt/rs/security/oauth-parent/oauth2/src/main/java/org/apache/cxf/rs/security/oauth2/provider/AbstractOAuthDataProvider.java @@ -241,6 +241,9 @@ public abstract class AbstractOAuthDataProvider implements OAuthDataProvider, Cl if (currentRefreshToken == null) { throw new OAuthServiceException(OAuthConstants.ACCESS_DENIED); } + if (!currentRefreshToken.getClient().getClientId().equals(client.getClientId())) { + throw new OAuthServiceException(OAuthConstants.INVALID_GRANT); + } if (OAuthUtils.isExpired(currentRefreshToken.getIssuedAt(), currentRefreshToken.getExpiresIn())) { if (!recycleRefreshTokens) { revokeRefreshToken(client, refreshTokenKey); diff --git a/rt/rs/security/oauth-parent/oauth2/src/test/java/org/apache/cxf/rs/security/oauth2/provider/AbstractOAuthDataProviderTest.java b/rt/rs/security/oauth-parent/oauth2/src/test/java/org/apache/cxf/rs/security/oauth2/provider/AbstractOAuthDataProviderTest.java index 0d160d352cd..782cad88b07 100644 --- a/rt/rs/security/oauth-parent/oauth2/src/test/java/org/apache/cxf/rs/security/oauth2/provider/AbstractOAuthDataProviderTest.java +++ b/rt/rs/security/oauth-parent/oauth2/src/test/java/org/apache/cxf/rs/security/oauth2/provider/AbstractOAuthDataProviderTest.java @@ -346,6 +346,38 @@ abstract class AbstractOAuthDataProviderTest { assertNull(getProvider().getRefreshToken(rt.getTokenKey())); } + /** + * Regression test for cross-client refresh token acceptance when recycleRefreshTokens=false. + * Client B must not be able to exchange Client A's refresh token for an access token. + */ + @Test + public void testCrossClientRefreshTokenRejectedWhenRecycleDisabled() { + getProvider().setRecycleRefreshTokens(false); + + // Client A obtains a refresh token for its resource owner. + Client clientA = addClient("101", "alice"); + AccessTokenRegistration atrA = new AccessTokenRegistration(); + atrA.setClient(clientA); + atrA.setApprovedScope(Arrays.asList("a", "refreshToken")); + atrA.setSubject(clientA.getResourceOwnerSubject()); + ServerAccessToken atA = getProvider().createAccessToken(atrA); + assertNotNull(atA.getRefreshToken()); + + // Client B is a separate registered client controlled by an attacker. + Client clientB = addClient("102", "bob"); + + // Attacker uses Client B credentials to present Client A's refresh token. + try { + getProvider().refreshAccessToken(clientB, atA.getRefreshToken(), Collections.emptyList()); + fail("Cross-client refresh token use must be rejected with OAuthServiceException"); + } catch (OAuthServiceException ex) { + assertEquals(OAuthConstants.INVALID_GRANT, ex.getMessage()); + } + + // Client A's original access token must remain usable. + assertNotNull(getProvider().getAccessToken(atA.getTokenKey())); + } + protected Client addClient(String clientId, String userLogin) { Client c = new Client(); c.setRedirectUris(Collections.singletonList("http://client/redirect"));
