This is an automated email from the ASF dual-hosted git repository.

coheigea pushed a commit to branch 4.1.x-fixes
in repository https://gitbox.apache.org/repos/asf/cxf.git


The following commit(s) were added to refs/heads/4.1.x-fixes by this push:
     new 08f91ef6088 Make sure that users can't revoke other users tokens 
(#3099)
08f91ef6088 is described below

commit 08f91ef6088939034af60898b94b6e61339cf997
Author: Colm O hEigeartaigh <[email protected]>
AuthorDate: Mon May 11 16:36:18 2026 +0100

    Make sure that users can't revoke other users tokens (#3099)
    
    (cherry picked from commit 7019b0fc05f9c203ae2fd6c07bedb7e65a2f0374)
---
 .../oauth2/provider/AbstractOAuthDataProvider.java | 31 +++++++++++-
 .../oauth2/provider/OAuthDataProvider.java         | 21 ++++++++
 .../oauth2/services/TokenRevocationService.java    | 35 +++++++++++++-
 .../provider/AbstractOAuthDataProviderTest.java    | 56 ++++++++++++++++++++++
 4 files changed, 140 insertions(+), 3 deletions(-)

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 26f74928490..8daef5e8839 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
@@ -251,14 +251,20 @@ public abstract class AbstractOAuthDataProvider 
implements OAuthDataProvider, Cl
 
     @Override
     public void revokeToken(Client client, String tokenKey, String 
tokenTypeHint) throws OAuthServiceException {
+        revokeToken(client, null, tokenKey, tokenTypeHint);
+    }
+
+    @Override
+    public void revokeToken(Client client, UserSubject callerSubject, String 
tokenKey, String tokenTypeHint)
+        throws OAuthServiceException {
         ServerAccessToken accessToken = null;
         if (!OAuthConstants.REFRESH_TOKEN.equals(tokenTypeHint)) {
-            accessToken = revokeAccessToken(client, tokenKey);
+            accessToken = revokeAccessToken(client, callerSubject, tokenKey);
         }
         if (accessToken != null) {
             handleLinkedRefreshToken(client, accessToken);
         } else if (!OAuthConstants.ACCESS_TOKEN.equals(tokenTypeHint)) {
-            RefreshToken currentRefreshToken = revokeRefreshToken(client, 
tokenKey);
+            RefreshToken currentRefreshToken = revokeRefreshToken(client, 
callerSubject, tokenKey);
             revokeAccessTokens(client, currentRefreshToken);
         }
     }
@@ -567,21 +573,42 @@ public abstract class AbstractOAuthDataProvider 
implements OAuthDataProvider, Cl
     }
 
     protected ServerAccessToken revokeAccessToken(Client client, String 
accessTokenKey) {
+        return revokeAccessToken(client, null, accessTokenKey);
+    }
+
+    protected ServerAccessToken revokeAccessToken(Client client, UserSubject 
callerSubject, String accessTokenKey) {
         ServerAccessToken at = getAccessToken(accessTokenKey);
         if (at != null) {
             if (!at.getClient().getClientId().equals(client.getClientId())) {
                 throw new OAuthServiceException(OAuthConstants.INVALID_GRANT);
             }
+            // IDOR guard: if the revocation request carries a resource owner 
identity,
+            // it must match the subject bound to the token.  This prevents 
User A from
+            // revoking User B's token when both share the same OAuth2 client.
+            if (callerSubject != null && at.getSubject() != null
+                && 
!callerSubject.getLogin().equals(at.getSubject().getLogin())) {
+                throw new OAuthServiceException(OAuthConstants.INVALID_GRANT);
+            }
             doRevokeAccessToken(at);
         }
         return at;
     }
+
     protected RefreshToken revokeRefreshToken(Client client, String 
refreshTokenKey) {
+        return revokeRefreshToken(client, null, refreshTokenKey);
+    }
+
+    protected RefreshToken revokeRefreshToken(Client client, UserSubject 
callerSubject, String refreshTokenKey) {
         RefreshToken refreshToken = getRefreshToken(refreshTokenKey);
         if (refreshToken != null) {
             if 
(!refreshToken.getClient().getClientId().equals(client.getClientId())) {
                 throw new OAuthServiceException(OAuthConstants.INVALID_GRANT);
             }
+            // IDOR guard: same subject check as for access tokens.
+            if (callerSubject != null && refreshToken.getSubject() != null
+                && 
!callerSubject.getLogin().equals(refreshToken.getSubject().getLogin())) {
+                throw new OAuthServiceException(OAuthConstants.INVALID_GRANT);
+            }
             doRevokeRefreshToken(refreshToken);
         }
         return refreshToken;
diff --git 
a/rt/rs/security/oauth-parent/oauth2/src/main/java/org/apache/cxf/rs/security/oauth2/provider/OAuthDataProvider.java
 
b/rt/rs/security/oauth-parent/oauth2/src/main/java/org/apache/cxf/rs/security/oauth2/provider/OAuthDataProvider.java
index 8178edd7f60..a01e28383c8 100644
--- 
a/rt/rs/security/oauth-parent/oauth2/src/main/java/org/apache/cxf/rs/security/oauth2/provider/OAuthDataProvider.java
+++ 
b/rt/rs/security/oauth-parent/oauth2/src/main/java/org/apache/cxf/rs/security/oauth2/provider/OAuthDataProvider.java
@@ -113,6 +113,27 @@ public interface OAuthDataProvider {
      */
     void revokeToken(Client client, String tokenId, String tokenTypeHint) 
throws OAuthServiceException;
 
+    /**
+     * Revokes a refresh or access token, verifying that the authenticated 
resource owner's
+     * identity ({@code callerSubject}) matches the subject bound to the token.
+     * This prevents one user from revoking another user's token when multiple 
resource
+     * owners share the same OAuth2 client (IDOR guard).
+     * <p>
+     * {@code callerSubject} may be {@code null} for machine-to-machine 
(client credentials)
+     * flows where no end-user principal is present; in that case only the 
client-level
+     * check is applied.
+     *
+     * @param client the authenticated client
+     * @param callerSubject the authenticated resource owner, or {@code null} 
for M2M flows
+     * @param tokenId token identifier
+     * @param tokenTypeHint can be access_token or refresh_token or null
+     * @throws OAuthServiceException
+     */
+    default void revokeToken(Client client, UserSubject callerSubject, String 
tokenId, String tokenTypeHint)
+        throws OAuthServiceException {
+        revokeToken(client, tokenId, tokenTypeHint);
+    }
+
     /**
      * Converts the requested scopes to the list of permissions.
      * The scopes are extracted from OAuth2 'scope' property which
diff --git 
a/rt/rs/security/oauth-parent/oauth2/src/main/java/org/apache/cxf/rs/security/oauth2/services/TokenRevocationService.java
 
b/rt/rs/security/oauth-parent/oauth2/src/main/java/org/apache/cxf/rs/security/oauth2/services/TokenRevocationService.java
index 98698f30762..967410e117e 100644
--- 
a/rt/rs/security/oauth-parent/oauth2/src/main/java/org/apache/cxf/rs/security/oauth2/services/TokenRevocationService.java
+++ 
b/rt/rs/security/oauth-parent/oauth2/src/main/java/org/apache/cxf/rs/security/oauth2/services/TokenRevocationService.java
@@ -25,7 +25,9 @@ import jakarta.ws.rs.Path;
 import jakarta.ws.rs.Produces;
 import jakarta.ws.rs.core.MultivaluedMap;
 import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.SecurityContext;
 import org.apache.cxf.rs.security.oauth2.common.Client;
+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.utils.OAuthConstants;
 
@@ -58,7 +60,7 @@ public class TokenRevocationService extends 
AbstractTokenService {
             return 
createErrorResponseFromErrorCode(OAuthConstants.UNSUPPORTED_TOKEN_TYPE);
         }
         try {
-            getDataProvider().revokeToken(client, token, tokenTypeHint);
+            getDataProvider().revokeToken(client, 
getCallerUserSubject(client), token, tokenTypeHint);
         } catch (OAuthServiceException ex) {
             // Spec: The authorization server responds with HTTP status code 
200 if the
             // token has been revoked successfully or if the client submitted 
an
@@ -66,4 +68,35 @@ public class TokenRevocationService extends 
AbstractTokenService {
         }
         return Response.ok().build();
     }
+
+    /**
+     * Resolves the resource owner identity from the JAX-RS security context.
+     * <p>
+     * In standard OAuth2 flows the revocation endpoint is authenticated by the
+     * <em>client</em> (HTTP Basic Auth, client_secret_post, mTLS, …), so
+     * {@code SecurityContext.getUserPrincipal()} normally carries the 
client's own
+     * name, not an end-user identity.  We must not mistake the client 
principal for
+     * a resource-owner subject, or we would block every legitimate 
client-initiated
+     * revocation (e.g. the canonical authorization-code flow where the client
+     * revokes its own token on logout).
+     * <p>
+     * A non-null {@link UserSubject} is returned only when a distinct end-user
+     * principal is present — i.e. the principal name differs from the 
authenticated
+     * client's {@code clientId}.  In that case the data provider will verify 
that
+     * the token belongs to that end-user (IDOR guard).  When the caller is the
+     * client itself, {@code null} is returned and only the client-level check 
applies.
+     */
+    private UserSubject getCallerUserSubject(Client client) {
+        SecurityContext sc = getMessageContext().getSecurityContext();
+        java.security.Principal principal = sc.getUserPrincipal();
+        if (principal == null || principal.getName() == null) {
+            return null;
+        }
+        // Principal name matches the client id → this is the client 
authenticating
+        // at the token endpoint, not an independent resource-owner identity.
+        if (principal.getName().equals(client.getClientId())) {
+            return null;
+        }
+        return new UserSubject(principal.getName());
+    }
 }
\ No newline at end of file
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 439b9d69a93..0d160d352cd 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
@@ -47,6 +47,7 @@ import static org.junit.Assert.assertNotEquals;
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
 
 abstract class AbstractOAuthDataProviderTest {
     private static KeyPair keyPair;
@@ -256,6 +257,59 @@ abstract class AbstractOAuthDataProviderTest {
         assertNotEquals(at.getSubject().getId(), at2.getSubject().getId());
     }
 
+    @Test
+    public void testRevokeAccessTokenIdorAcrossUsersOfSharedClient() {
+        // A multi-user shared client (e.g. a public mobile app with a known 
client_id).
+        // Its resourceOwnerSubject is intentionally null — many users share 
this client.
+        Client sharedClient = new Client();
+        
sharedClient.setRedirectUris(Collections.singletonList("http://client/redirect";));
+        sharedClient.setClientId("103");
+        sharedClient.setClientSecret("secret");
+        getProvider().setClient(sharedClient);
+
+        // Alice obtains an access token via the shared client.
+        AccessTokenRegistration aliceReg = new AccessTokenRegistration();
+        aliceReg.setClient(sharedClient);
+        aliceReg.setApprovedScope(Collections.singletonList("a"));
+        aliceReg.setSubject(new UserSubject("alice"));
+        ServerAccessToken aliceToken = 
getProvider().createAccessToken(aliceReg);
+        assertNotNull(getProvider().getAccessToken(aliceToken.getTokenKey()));
+
+        // Bob obtains a separate access token via the same shared client.
+        AccessTokenRegistration bobReg = new AccessTokenRegistration();
+        bobReg.setClient(sharedClient);
+        bobReg.setApprovedScope(Collections.singletonList("a"));
+        bobReg.setSubject(new UserSubject("bob"));
+        ServerAccessToken bobToken = getProvider().createAccessToken(bobReg);
+        assertNotNull(getProvider().getAccessToken(bobToken.getTokenKey()));
+
+        // IDOR attempt: Bob (authenticated only as the shared client) submits 
Alice's token
+        // key to the revocation endpoint. The subject-aware revokeToken 
overload is called
+        // with Bob's identity, which must not match Alice's subject.
+        // The data provider must reject this with OAuthServiceException; the 
HTTP layer
+        // (TokenRevocationService) swallows it per RFC 7009 and returns 200 
anyway.
+        try {
+            getProvider().revokeToken(sharedClient, new UserSubject("bob"), 
aliceToken.getTokenKey(),
+                                      OAuthConstants.ACCESS_TOKEN);
+            // Reaching here means the cross-user revocation was NOT blocked — 
fail the test.
+            fail(
+                "IDOR: revokeToken should have thrown OAuthServiceException 
when Bob tried to "
+                + "revoke Alice's token, but it succeeded silently"
+            );
+        } catch (OAuthServiceException expected) {
+            // Expected: the subject mismatch guard correctly rejected the 
request.
+        }
+
+        // Alice's token must still be intact after the blocked revocation 
attempt.
+        assertNotNull(
+            "Alice's token must survive a cross-user revocation attempt",
+            getProvider().getAccessToken(aliceToken.getTokenKey())
+        );
+
+        // Bob's own token must remain unaffected regardless.
+        assertNotNull(getProvider().getAccessToken(bobToken.getTokenKey()));
+    }
+
     @Test
     public void testAddGetDeleteRefreshToken() {
         Client c = addClient("101", "bob");
@@ -330,6 +384,8 @@ abstract class AbstractOAuthDataProviderTest {
 
     protected void tearDownClients() {
         tearDownClient("101");
+        tearDownClient("102");
+        tearDownClient("103");
         tearDownClient("12345");
         tearDownClient("56789");
         tearDownClient("09876");

Reply via email to