diff --git a/rt/rs/security/oauth-parent/oauth2/src/main/java/org/apache/cxf/rs/security/oauth2/grants/code/JPACMTCodeDataProvider.java b/rt/rs/security/oauth-parent/oauth2/src/main/java/org/apache/cxf/rs/security/oauth2/grants/code/JPACMTCodeDataProvider.java index 18101769825..8745ffaf3a4 100644 --- a/rt/rs/security/oauth-parent/oauth2/src/main/java/org/apache/cxf/rs/security/oauth2/grants/code/JPACMTCodeDataProvider.java +++ b/rt/rs/security/oauth-parent/oauth2/src/main/java/org/apache/cxf/rs/security/oauth2/grants/code/JPACMTCodeDataProvider.java @@ -25,9 +25,12 @@ import jakarta.persistence.EntityManager; import jakarta.persistence.EntityTransaction; import jakarta.persistence.LockModeType; +import org.apache.cxf.rs.security.oauth2.common.Client; import org.apache.cxf.rs.security.oauth2.common.ServerAccessToken; +import org.apache.cxf.rs.security.oauth2.common.UserSubject; import org.apache.cxf.rs.security.oauth2.provider.OAuthServiceException; import org.apache.cxf.rs.security.oauth2.tokens.refresh.RefreshToken; +import org.apache.cxf.rs.security.oauth2.utils.OAuthConstants; /** * Same as {@link JPACodeDataProvider} (stores Clients and tokens in a rdbms using @@ -122,6 +125,28 @@ protected void commitIfNeeded(EntityManager em) { protected void closeIfNeeded(EntityManager em) { } + @Override + protected RefreshToken revokeRefreshToken(Client client, UserSubject callerSubject, String refreshTokenKey) { + // Atomic find + validate + delete with lock timeout hint, parallel to removeCodeGrant. + final Map options = new HashMap<>(); + options.put(JPA_LOCK_TIMEOUT_HINT, pessimisticLockTimeout); + return executeInTransaction(em -> { + RefreshToken refreshToken = em.find(RefreshToken.class, refreshTokenKey, + LockModeType.PESSIMISTIC_WRITE, options); + if (refreshToken != null) { + if (!refreshToken.getClient().getClientId().equals(client.getClientId())) { + throw new OAuthServiceException(OAuthConstants.INVALID_GRANT); + } + if (callerSubject != null && refreshToken.getSubject() != null + && !callerSubject.getLogin().equals(refreshToken.getSubject().getLogin())) { + throw new OAuthServiceException(OAuthConstants.INVALID_GRANT); + } + em.remove(refreshToken); + } + return refreshToken; + }); + } + @Override protected RefreshToken updateExistingRefreshToken(RefreshToken rt, ServerAccessToken at) { if (useJpaLockForExistingRefreshToken) { diff --git a/rt/rs/security/oauth-parent/oauth2/src/main/java/org/apache/cxf/rs/security/oauth2/provider/JPAOAuthDataProvider.java b/rt/rs/security/oauth-parent/oauth2/src/main/java/org/apache/cxf/rs/security/oauth2/provider/JPAOAuthDataProvider.java index 9f3fbb96388..eb5ed948f38 100644 --- a/rt/rs/security/oauth-parent/oauth2/src/main/java/org/apache/cxf/rs/security/oauth2/provider/JPAOAuthDataProvider.java +++ b/rt/rs/security/oauth-parent/oauth2/src/main/java/org/apache/cxf/rs/security/oauth2/provider/JPAOAuthDataProvider.java @@ -28,6 +28,7 @@ import jakarta.persistence.EntityManager; import jakarta.persistence.EntityManagerFactory; import jakarta.persistence.EntityTransaction; +import jakarta.persistence.LockModeType; import jakarta.persistence.TypedQuery; import org.apache.cxf.helpers.CastUtils; import org.apache.cxf.rs.security.oauth2.common.AccessTokenRegistration; @@ -37,6 +38,7 @@ import org.apache.cxf.rs.security.oauth2.common.UserSubject; import org.apache.cxf.rs.security.oauth2.tokens.bearer.BearerAccessToken; import org.apache.cxf.rs.security.oauth2.tokens.refresh.RefreshToken; +import org.apache.cxf.rs.security.oauth2.utils.OAuthConstants; /** * Provides a Jpa BMT implementation for OAuthDataProvider. @@ -190,6 +192,26 @@ protected RefreshToken getRefreshToken(final String refreshTokenKey) { }); } + @Override + protected RefreshToken revokeRefreshToken(Client client, UserSubject callerSubject, String refreshTokenKey) { + // Atomic find + validate + delete in one transaction prevents concurrent replay of the same refresh token. + return executeInTransaction(em -> { + RefreshToken refreshToken = em.find(RefreshToken.class, refreshTokenKey, + LockModeType.PESSIMISTIC_WRITE); + if (refreshToken != null) { + if (!refreshToken.getClient().getClientId().equals(client.getClientId())) { + throw new OAuthServiceException(OAuthConstants.INVALID_GRANT); + } + if (callerSubject != null && refreshToken.getSubject() != null + && !callerSubject.getLogin().equals(refreshToken.getSubject().getLogin())) { + throw new OAuthServiceException(OAuthConstants.INVALID_GRANT); + } + em.remove(refreshToken); + } + return refreshToken; + }); + } + @Override protected void doRevokeRefreshToken(final RefreshToken rt) { executeInTransaction(em -> { 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 782cad88b07..732fb18e58c 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 @@ -350,6 +350,31 @@ public void testAddGetDeleteRefreshToken() { * 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 testRefreshTokenSingleUseEnforcedWhenRecycled() { + Client c = addClient("101", "bob"); + + AccessTokenRegistration atr = new AccessTokenRegistration(); + atr.setClient(c); + atr.setApprovedScope(Arrays.asList("a", "refreshToken")); + atr.setSubject(c.getResourceOwnerSubject()); + + ServerAccessToken at = getProvider().createAccessToken(atr); + String rtKey = at.getRefreshToken(); + assertNotNull("Expected a refresh token to be issued", rtKey); + + // First use must succeed and invalidate the original token. + getProvider().refreshAccessToken(c, rtKey, Collections.emptyList()); + + // Second use of the same (now consumed) refresh token must be denied. + try { + getProvider().refreshAccessToken(c, rtKey, Collections.emptyList()); + fail("Replayed refresh token must be rejected"); + } catch (OAuthServiceException ex) { + assertEquals(OAuthConstants.ACCESS_DENIED, ex.getMessage()); + } + } + @Test public void testCrossClientRefreshTokenRejectedWhenRecycleDisabled() { getProvider().setRecycleRefreshTokens(false);