DefaultEncryptingOAuthDataProviderTest.java

/**
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements. See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership. The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License. You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied. See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */
package org.apache.cxf.rs.security.oauth2.provider;

import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;

import org.apache.cxf.rs.security.oauth2.common.AccessTokenRegistration;
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.tokens.refresh.RefreshToken;
import org.apache.cxf.rs.security.oauth2.utils.OAuthConstants;
import org.apache.cxf.rt.security.crypto.KeyProperties;

import org.junit.After;
import org.junit.Before;
import org.junit.Test;

import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;


public class DefaultEncryptingOAuthDataProviderTest {
    private static class BlockingRefreshProvider extends DefaultEncryptingOAuthDataProvider {
        private final CountDownLatch enteredUpdate = new CountDownLatch(1);
        private final CountDownLatch allowUpdate = new CountDownLatch(1);

        BlockingRefreshProvider() {
            super(new KeyProperties("AES", 128));
        }

        @Override
        protected RefreshToken updateExistingRefreshToken(RefreshToken rt, ServerAccessToken at) {
            enteredUpdate.countDown();
            try {
                if (!allowUpdate.await(5, TimeUnit.SECONDS)) {
                    throw new OAuthServiceException("Timed out waiting to continue refresh update");
                }
            } catch (InterruptedException ex) {
                Thread.currentThread().interrupt();
                throw new OAuthServiceException("Interrupted while waiting to continue refresh update", ex);
            }
            // For this lock-ordering regression we only need to hold and release the lock,
            // not to exercise refresh-token list mutation semantics.
            return rt;
        }

        boolean awaitEnteredUpdate(long timeout, TimeUnit unit) throws InterruptedException {
            return enteredUpdate.await(timeout, unit);
        }

        void continueUpdate() {
            allowUpdate.countDown();
        }
    }

    private DefaultEncryptingOAuthDataProvider provider;

    @Before
    public void setUp() throws Exception {
        // Initialize provider with AES encryption (128-bit key)
        provider = new DefaultEncryptingOAuthDataProvider(new KeyProperties("AES", 128));
        // Register supported scopes
        provider.setSupportedScopes(Collections.singletonMap("read", "Read Scope"));
        provider.setSupportedScopes(Collections.singletonMap("refreshToken", "RefreshToken"));
    }

    @After
    public void tearDown() throws Exception {
        if (provider != null) {
            provider.close();
        }
    }

    private Client createTestClient(String clientId, String userLogin) {
        Client c = new Client();
        c.setRedirectUris(Collections.singletonList("http://client/redirect"));
        c.setClientId(clientId);
        c.setClientSecret("secret");
        c.setResourceOwnerSubject(new UserSubject(userLogin));
        provider.setClient(c);
        return c;
    }

    /**
     * RFC 7009 ��2.1 compliance test: Revoked access tokens must become inaccessible.
     * 
     * Scenario:
     * 1. Create an access token
     * 2. Verify it is accessible via getAccessToken()
     * 3. Revoke the token
     * 4. Verify it is NO LONGER accessible (returns null)
     * 
     * This test ensures the fix for the revocation bypass vulnerability
     * where getAccessToken() was not checking the revoked tokens set.
     */
    @Test
    public void testRevokedAccessTokenReturnsNull() {
        Client c = createTestClient("test1", "alice");
        
        AccessTokenRegistration atr = new AccessTokenRegistration();
        atr.setClient(c);
        atr.setApprovedScope(Collections.singletonList("read"));
        atr.setSubject(c.getResourceOwnerSubject());
        
        // Step 1: Create and verify token is accessible
        ServerAccessToken token = provider.createAccessToken(atr);
        assertNotNull("Token should be accessible immediately after creation",
                      provider.getAccessToken(token.getTokenKey()));
        
        // Step 2: Revoke the token
        provider.revokeToken(c, token.getTokenKey(), OAuthConstants.ACCESS_TOKEN);
        
        // Step 3: CRITICAL: Verify revoked token is no longer accessible
        // Before the fix, this would return the decrypted token (VULNERABILITY)
        // After the fix, this must return null (RFC 7009 ��2.1 compliance)
        assertNull("Revoked access token must return null (RFC 7009 ��2.1)",
                   provider.getAccessToken(token.getTokenKey()));
    }

    /**
     * RFC 7009 ��2.1 and RFC 7662 ��2.2 compliance test: Revoked refresh tokens
     * must become inaccessible.
     * 
     * Scenario:
     * 1. Create an access token with refresh token capability
     * 2. Verify refresh token is accessible
     * 3. Revoke the refresh token
     * 4. Verify it is NO LONGER accessible (returns null)
     * 
     * This test prevents the attack where a revoked refresh token
     * could mint new access tokens.
     */
    @Test
    public void testRevokedRefreshTokenReturnsNull() {
        Client c = createTestClient("test2", "bob");
        
        AccessTokenRegistration atr = new AccessTokenRegistration();
        atr.setClient(c);
        atr.setApprovedScope(Arrays.asList("read", "refreshToken"));
        atr.setSubject(c.getResourceOwnerSubject());
        
        // Step 1: Create access token with refresh token
        ServerAccessToken at = provider.createAccessToken(atr);
        String refreshTokenKey = at.getRefreshToken();
        
        // Step 2: Verify refresh token is accessible before revocation
        RefreshToken rt = provider.getRefreshToken(refreshTokenKey);
        assertNotNull("Refresh token should be accessible immediately after creation", rt);
        
        // Step 3: Revoke the refresh token
        provider.revokeToken(c, refreshTokenKey, OAuthConstants.REFRESH_TOKEN);
        
        // Step 4: CRITICAL: Verify revoked refresh token is no longer accessible
        // Before the fix, this would return the decrypted token (VULNERABILITY)
        // After the fix, this must return null (RFC 7009 ��2.1 compliance)
        assertNull("Revoked refresh token must return null (RFC 7009 ��2.1)",
                   provider.getRefreshToken(refreshTokenKey));
    }

    /**
     * Security test: Verify multiple tokens can be managed independently.
     * 
     * Scenario:
     * 1. Create two access tokens for the same client
     * 2. Revoke only the first token
     * 3. Verify first token is null (revoked)
     * 4. Verify second token is still accessible (not revoked)
     * 
     * This test ensures the revocation mechanism is token-specific
     * and doesn't inadvertently revoke other tokens.
     */
    @Test
    public void testSelectiveAccessTokenRevocation() {
        Client c = createTestClient("test3", "charlie");
        
        // Create first token
        AccessTokenRegistration atr1 = new AccessTokenRegistration();
        atr1.setClient(c);
        atr1.setApprovedScope(Collections.singletonList("read"));
        atr1.setSubject(c.getResourceOwnerSubject());
        ServerAccessToken token1 = provider.createAccessToken(atr1);
        
        // Create second token
        AccessTokenRegistration atr2 = new AccessTokenRegistration();
        atr2.setClient(c);
        atr2.setApprovedScope(Collections.singletonList("read"));
        atr2.setSubject(c.getResourceOwnerSubject());
        ServerAccessToken token2 = provider.createAccessToken(atr2);
        
        // Verify both are initially accessible
        assertNotNull("Token 1 should be accessible", 
                      provider.getAccessToken(token1.getTokenKey()));
        assertNotNull("Token 2 should be accessible", 
                      provider.getAccessToken(token2.getTokenKey()));
        
        // Revoke only token 1
        provider.revokeToken(c, token1.getTokenKey(), OAuthConstants.ACCESS_TOKEN);
        
        // Verify only token 1 is revoked
        assertNull("Token 1 must be revoked", 
                   provider.getAccessToken(token1.getTokenKey()));
        assertNotNull("Token 2 must still be accessible after revoking Token 1", 
                      provider.getAccessToken(token2.getTokenKey()));
    }

    /**
     * RFC 7662 ��2.2 compliance test: Token introspection consistency.
     * 
     * Scenario:
     * 1. Create an access token
     * 2. Revoke it
     * 3. Attempt to retrieve it (simulates introspection)
     * 4. Verify null is returned (service layer converts to active:false)
     * 
     * This ensures that TokenIntrospectionService will correctly report
     * active:false for revoked tokens by returning null.
     */
    @Test
    public void testIntrospectionReportRevocationCorrectly() {
        Client c = createTestClient("test6", "frank");
        
        AccessTokenRegistration atr = new AccessTokenRegistration();
        atr.setClient(c);
        atr.setApprovedScope(Collections.singletonList("read"));
        atr.setSubject(c.getResourceOwnerSubject());
        
        ServerAccessToken token = provider.createAccessToken(atr);
        String tokenKey = token.getTokenKey();
        
        // Before revocation: introspection should find the token
        ServerAccessToken beforeRevoke = provider.getAccessToken(tokenKey);
        assertNotNull("Token should be found before revocation (introspection returns details)", 
                      beforeRevoke);
        
        // Revoke the token
        provider.revokeToken(c, tokenKey, OAuthConstants.ACCESS_TOKEN);
        
        // After revocation: introspection should return null
        // (higher-level code like TokenIntrospectionService converts this to active:false)
        ServerAccessToken afterRevoke = provider.getAccessToken(tokenKey);
        assertNull("Token should return null after revocation (introspection reports inactive)", 
                   afterRevoke);
    }

    /**
     * Security test: Verify that refresh tokens cannot be reused after revocation.
     * 
     * Scenario:
     * 1. Create an access token with refresh token
     * 2. Use the refresh token to create a new access token (verify it works)
     * 3. Revoke the refresh token
     * 4. Attempt to use the refresh token again (verify it fails)
     * 
     * This test ensures refresh token revocation prevents any further use
     * of that token for minting new access tokens.
     */
    @Test
    public void testRevokedRefreshTokenCannotMintNewTokens() {
        Client c = createTestClient("test5", "eve");
        
        AccessTokenRegistration atr = new AccessTokenRegistration();
        atr.setClient(c);
        atr.setApprovedScope(Arrays.asList("read", "refreshToken"));
        atr.setSubject(c.getResourceOwnerSubject());
        
        // Create initial access token with refresh token
        ServerAccessToken initialToken = provider.createAccessToken(atr);
        String refreshTokenKey = initialToken.getRefreshToken();
        
        // Verify refresh token can be retrieved before revocation
        RefreshToken rt = provider.getRefreshToken(refreshTokenKey);
        assertNotNull("Refresh token should be accessible before revocation", rt);
        
        // Revoke the refresh token
        provider.revokeToken(c, refreshTokenKey, OAuthConstants.REFRESH_TOKEN);
        
        // CRITICAL: Verify refresh token cannot be accessed after revocation
        // This prevents using the revoked refresh token to mint new access tokens
        assertNull("Revoked refresh token must return null, preventing token minting", 
                   provider.getRefreshToken(refreshTokenKey));
    }

    @Test
    public void testRevokeWaitsForRefreshWhenRecycleDisabled() throws Exception {
        BlockingRefreshProvider blockingProvider = new BlockingRefreshProvider();
        try {
            blockingProvider.setSupportedScopes(Collections.singletonMap("read", "Read Scope"));
            blockingProvider.setSupportedScopes(Collections.singletonMap("refreshToken", "RefreshToken"));
            blockingProvider.setRecycleRefreshTokens(false);

            Client c = new Client();
            c.setRedirectUris(Collections.singletonList("http://client/redirect"));
            c.setClientId("race-client");
            c.setClientSecret("secret");
            c.setResourceOwnerSubject(new UserSubject("race-user"));
            blockingProvider.setClient(c);

            AccessTokenRegistration atr = new AccessTokenRegistration();
            atr.setClient(c);
            atr.setApprovedScope(Arrays.asList("read", "refreshToken"));
            atr.setSubject(c.getResourceOwnerSubject());

            ServerAccessToken initial = blockingProvider.createAccessToken(atr);
            String refreshTokenKey = initial.getRefreshToken();

            AtomicReference<Throwable> refreshFailure = new AtomicReference<>();
            AtomicReference<Throwable> revokeFailure = new AtomicReference<>();

            Thread refreshThread = new Thread(() -> {
                try {
                    blockingProvider.refreshAccessToken(c, refreshTokenKey, Collections.emptyList());
                } catch (Throwable ex) {
                    refreshFailure.set(ex);
                }
            });

            refreshThread.start();
            assertTrue("Refresh thread should reach the refresh-token update phase",
                       blockingProvider.awaitEnteredUpdate(5, TimeUnit.SECONDS));

            Thread revokeThread = new Thread(() -> {
                try {
                    blockingProvider.revokeToken(c, refreshTokenKey, OAuthConstants.REFRESH_TOKEN);
                } catch (Throwable ex) {
                    revokeFailure.set(ex);
                }
            });

            revokeThread.start();
            revokeThread.join(200);
            assertTrue("Revoke must block while refresh holds refreshTokenLock", revokeThread.isAlive());

            blockingProvider.continueUpdate();

            refreshThread.join(5000);
            revokeThread.join(5000);

            assertTrue("Refresh thread must finish", !refreshThread.isAlive());
            assertTrue("Revoke thread must finish after refresh releases lock", !revokeThread.isAlive());
            assertNull("Refresh thread must not fail", refreshFailure.get());
            assertNull("Revoke thread must not fail", revokeFailure.get());
            assertNull("Refresh token should be revoked after revoke thread completes",
                       blockingProvider.getRefreshToken(refreshTokenKey));

            List<ServerAccessToken> remaining = blockingProvider.getAccessTokens(c, c.getResourceOwnerSubject());
            for (ServerAccessToken token : remaining) {
                assertNull("No remaining access token should reference a revoked refresh token",
                           token.getRefreshToken());
            }
        } finally {
            blockingProvider.close();
        }
    }
}