[java-idp-oidc] branch main updated: JOIDC-92 - Support for refresh token rotation

Henri Mikkonen henri.mikkonen at iki.fi
Tue Jun 7 11:44:02 UTC 2022


This is an automated email from the git hooks/post-receive script.

hjmikkon pushed a commit to branch main
in repository java-idp-oidc.

View the commit online:
http://git.shibboleth.net/view/?p=java-idp-oidc.git;a=commit;h=f973137df55eeb50b04643814bc478185d4b3acd

The following commit(s) were added to refs/heads/main by this push:
     new f973137d JOIDC-92 - Support for refresh token rotation
f973137d is described below

commit f973137df55eeb50b04643814bc478185d4b3acd
Author: Henri Mikkonen <henri.mikkonen at iki.fi>
AuthorDate: Tue Jun 7 14:42:21 2022 +0300

    JOIDC-92 - Support for refresh token rotation
    
    https://shibboleth.atlassian.net/browse/JOIDC-92
    
    - Always include the full chain (all refresh and access tokens), if revoked refresh token is being used
    - Token endpoint revokes a single refresh token when it’s being used, if profile configuration enforces refresh token rotation
---
 .../impl/SetRefreshTokenToResponseContext.java     |  57 +++++++++++-
 .../plugin/oidc/op/profile/impl/ValidateGrant.java |  54 ++++++++++-
 .../idp/flows/oidc/token/token-beans.xml           |   4 +-
 .../plugin/oidc/op/profile/flow/TokenFlowTest.java |  68 +++++++++++++-
 .../impl/SetRefreshTokenToResponseContextTest.java |  48 ++++++++++
 .../oidc/op/profile/impl/ValidateGrantTest.java    | 100 ++++++++++++++++++---
 .../src/test/resources/conf/relying-party.xml      |   8 ++
 7 files changed, 323 insertions(+), 16 deletions(-)

diff --git a/idp-oidc-extension-impl/src/main/java/net/shibboleth/idp/plugin/oidc/op/profile/impl/SetRefreshTokenToResponseContext.java b/idp-oidc-extension-impl/src/main/java/net/shibboleth/idp/plugin/oidc/op/profile/impl/SetRefreshTokenToResponseContext.java
index 18581fe2..b3e151cd 100644
--- a/idp-oidc-extension-impl/src/main/java/net/shibboleth/idp/plugin/oidc/op/profile/impl/SetRefreshTokenToResponseContext.java
+++ b/idp-oidc-extension-impl/src/main/java/net/shibboleth/idp/plugin/oidc/op/profile/impl/SetRefreshTokenToResponseContext.java
@@ -23,12 +23,14 @@ import java.time.Instant;
 import java.util.Map;
 import java.util.function.BiFunction;
 import java.util.function.Function;
+import java.util.function.Predicate;
 
 import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
 
 import org.opensaml.profile.action.EventIds;
 import org.opensaml.profile.context.ProfileRequestContext;
+import org.opensaml.storage.RevocationCache;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -36,15 +38,19 @@ import com.nimbusds.jwt.JWTClaimsSet;
 import com.nimbusds.openid.connect.sdk.OIDCScopeValue;
 
 import net.shibboleth.idp.plugin.oidc.op.messaging.context.OIDCAuthenticationResponseContext;
+import net.shibboleth.idp.plugin.oidc.op.storage.RevocationCacheContexts;
 import net.shibboleth.idp.plugin.oidc.op.token.support.AuthorizeCodeClaimsSet;
 import net.shibboleth.idp.plugin.oidc.op.token.support.RefreshTokenClaimsSet;
 import net.shibboleth.idp.plugin.oidc.op.token.support.TokenClaimsSet;
 import net.shibboleth.idp.profile.IdPEventIds;
+import net.shibboleth.oidc.profile.config.logic.EnforceRefreshTokenRotationPredicate;
 import net.shibboleth.oidc.profile.config.navigate.RefreshTokenClaimsSetManipulationStrategyLookupFunction;
 import net.shibboleth.oidc.profile.config.navigate.RefreshTokenLifetimeLookupFunction;
 
 import org.opensaml.profile.action.ActionSupport;
 import net.shibboleth.utilities.java.support.annotation.ParameterName;
+import net.shibboleth.utilities.java.support.annotation.constraint.NonnullAfterInit;
+import net.shibboleth.utilities.java.support.component.ComponentInitializationException;
 import net.shibboleth.utilities.java.support.component.ComponentSupport;
 import net.shibboleth.utilities.java.support.logic.Constraint;
 import net.shibboleth.utilities.java.support.logic.FunctionSupport;
@@ -68,6 +74,9 @@ public class SetRefreshTokenToResponseContext extends AbstractOIDCResponseAction
     /** Data sealer for handling access token. */
     @Nonnull private final DataSealer dataSealer;
 
+    /** Message revocation cache instance to use. */
+    @NonnullAfterInit private RevocationCache revocationCache;
+
     /** Strategy used to obtain the refresh token lifetime. */
     @Nonnull private Function<ProfileRequestContext,Duration> refreshTokenLifetimeLookupStrategy;
 
@@ -82,6 +91,9 @@ public class SetRefreshTokenToResponseContext extends AbstractOIDCResponseAction
     /** Strategy used to locate the {@link IdentifierGenerationStrategy} to use. */
     @Nonnull private Function<ProfileRequestContext,IdentifierGenerationStrategy> idGeneratorLookupStrategy;
 
+    /** Strategy used to determine whether to revoke refresh tokens once they're used. */
+    @Nonnull private Predicate<ProfileRequestContext> enforceRefreshTokenRotationCondition;
+
     /** Authorize Code / Refresh Token the refresh token will be based on. */
     @Nullable private TokenClaimsSet tokenClaimsSet;
     
@@ -102,6 +114,17 @@ public class SetRefreshTokenToResponseContext extends AbstractOIDCResponseAction
         tokenClaimsSetManipulationStrategyLookupStrategy =
                 new RefreshTokenClaimsSetManipulationStrategyLookupFunction();
         idGeneratorLookupStrategy = FunctionSupport.constant(new SecureRandomIdentifierGenerationStrategy());
+        enforceRefreshTokenRotationCondition = new EnforceRefreshTokenRotationPredicate();
+    }
+
+    /**
+     * Set the revocation cache instance to use.
+     * 
+     * @param cache The revocationCache to set.
+     */
+    public void setRevocationCache(@Nonnull final RevocationCache cache) {
+        ComponentSupport.ifInitializedThrowUnmodifiabledComponentException(this);
+        revocationCache = Constraint.isNotNull(cache, "RevocationCache cannot be null");
     }
 
     /**
@@ -144,6 +167,27 @@ public class SetRefreshTokenToResponseContext extends AbstractOIDCResponseAction
                 Constraint.isNotNull(strategy, "Identifier generation strategy cannot be null");
     }
 
+    /**
+     * Set the condition used to determine whether to revoke refresh tokens once they're used.
+     * 
+     * @param condition condition to apply
+     */
+    public void setEnforceRefreshTokenRotationCondition(@Nonnull final Predicate<ProfileRequestContext> condition) {
+        ComponentSupport.ifInitializedThrowUnmodifiabledComponentException(this);
+
+        enforceRefreshTokenRotationCondition = Constraint.isNotNull(condition, "Condition cannot be null");
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void doInitialize() throws ComponentInitializationException {
+        super.doInitialize();
+        
+        if (revocationCache == null) {
+            throw new ComponentInitializationException("RevocationCache cannot be null");
+        }
+    }
+
     /** {@inheritDoc} */
     @Override
     protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
@@ -195,7 +239,7 @@ public class SetRefreshTokenToResponseContext extends AbstractOIDCResponseAction
                 .setJWTID(idGenerator)
                 .setRootTokenIdentifier(rootTokenId)
                 .build();
-
+        
         if (manipulationStrategy != null) {
             log.debug("{} Manipulation strategy has been set, applying it to the claims set {}", getLogPrefix(),
                     claimsSet.serialize());
@@ -226,6 +270,17 @@ public class SetRefreshTokenToResponseContext extends AbstractOIDCResponseAction
             log.error("{} Refresh Token generation failed {}", getLogPrefix(), e.getMessage());
             ActionSupport.buildEvent(profileRequestContext, EventIds.UNABLE_TO_ENCRYPT);
         }
+
+        if (enforceRefreshTokenRotationCondition.test(profileRequestContext) && 
+                tokenClaimsSet instanceof RefreshTokenClaimsSet) {
+            final String jti = tokenClaimsSet.getID();
+            log.debug("{} Revoking the refresh token {} used for issuing the new one", getLogPrefix(), jti);
+            if (!revocationCache.revoke(RevocationCacheContexts.SINGLE_ACCESS_OR_REFRESH_TOKENS, jti)) {
+                log.error("{} Unable to store revocation into the revocation cache", getLogPrefix());
+                ActionSupport.buildEvent(profileRequestContext, IdPEventIds.INVALID_PROFILE_CONFIG);
+                return;
+            }
+        }
     }
 
 }
\ No newline at end of file
diff --git a/idp-oidc-extension-impl/src/main/java/net/shibboleth/idp/plugin/oidc/op/profile/impl/ValidateGrant.java b/idp-oidc-extension-impl/src/main/java/net/shibboleth/idp/plugin/oidc/op/profile/impl/ValidateGrant.java
index 4596acdd..38a83a8b 100644
--- a/idp-oidc-extension-impl/src/main/java/net/shibboleth/idp/plugin/oidc/op/profile/impl/ValidateGrant.java
+++ b/idp-oidc-extension-impl/src/main/java/net/shibboleth/idp/plugin/oidc/op/profile/impl/ValidateGrant.java
@@ -18,6 +18,7 @@
 package net.shibboleth.idp.plugin.oidc.op.profile.impl;
 
 import java.text.ParseException;
+import java.time.Duration;
 import java.util.function.Function;
 import java.util.function.Predicate;
 
@@ -45,12 +46,14 @@ import net.shibboleth.idp.plugin.oidc.op.token.support.TokenClaimsSet;
 import net.shibboleth.idp.profile.IdPEventIds;
 import net.shibboleth.idp.profile.context.RelyingPartyContext;
 import net.shibboleth.oidc.profile.config.logic.RefreshTokensEnabledPredicate;
+import net.shibboleth.oidc.profile.config.navigate.RefreshTokenLifetimeLookupFunction;
 import net.shibboleth.oidc.profile.core.OidcEventIds;
 import net.shibboleth.utilities.java.support.annotation.ParameterName;
 import net.shibboleth.utilities.java.support.annotation.constraint.NonnullAfterInit;
 import net.shibboleth.utilities.java.support.component.ComponentInitializationException;
 import net.shibboleth.utilities.java.support.component.ComponentSupport;
 import net.shibboleth.utilities.java.support.logic.Constraint;
+import net.shibboleth.utilities.java.support.primitive.StringSupport;
 import net.shibboleth.utilities.java.support.security.DataSealer;
 import net.shibboleth.utilities.java.support.security.DataSealerException;
 
@@ -89,6 +92,9 @@ public class ValidateGrant extends AbstractOIDCTokenResponseAction {
     /** Predicate used to indicate whether refresh tokens are enabled. */
     @Nonnull private Predicate<ProfileRequestContext> refreshTokensEnabledPredicate;
 
+    /** Strategy used to lookup the duration for lifetime of an entry in the token revocation cache. */
+    @Nonnull private Function<ProfileRequestContext, Duration> tokenRevocationLifetimeLookupStrategy;
+
     /** The RelyingPartyContext to operate on. */
     @Nullable private RelyingPartyContext rpCtx;
 
@@ -101,6 +107,7 @@ public class ValidateGrant extends AbstractOIDCTokenResponseAction {
         dataSealer = Constraint.isNotNull(sealer, "DataSealer cannot be null");
         relyingPartyContextLookupStrategy = new ChildContextLookup<>(RelyingPartyContext.class);
         refreshTokensEnabledPredicate = new RefreshTokensEnabledPredicate();
+        tokenRevocationLifetimeLookupStrategy = new RefreshTokenLifetimeLookupFunction();
     }
 
     /**
@@ -149,6 +156,18 @@ public class ValidateGrant extends AbstractOIDCTokenResponseAction {
         revocationCache = Constraint.isNotNull(cache, "RevocationCache cannot be null");
     }
 
+    /**
+     * Set the strategy used to lookup the duration for lifetime of an entry in the token revocation cache.
+     *
+     * @param strategy The strategy to set.
+     */
+    public void setTokenRevocationLifetimeLookupStrategy(
+            @Nonnull final Function<ProfileRequestContext, Duration> strategy) {
+        ComponentSupport.ifInitializedThrowUnmodifiabledComponentException(this);
+        tokenRevocationLifetimeLookupStrategy = Constraint.isNotNull(strategy,
+                "The lookup strategy cannot be null");
+    }
+
     /** {@inheritDoc} */
     @Override
     protected void doInitialize() throws ComponentInitializationException {
@@ -222,10 +241,39 @@ public class ValidateGrant extends AbstractOIDCTokenResponseAction {
                 try {
                     final RefreshTokenClaimsSet refreshTokenClaimsSet = RefreshTokenClaimsSet
                             .parse(refreshTokentokenGrant.getRefreshToken().getValue(), dataSealer);
-                    if (revocationCache.isRevoked(RevocationCacheContexts.AUTHORIZATION_CODE,
-                            refreshTokenClaimsSet.getID())) {
+                    final String rootJti = refreshTokenClaimsSet.getRootTokenIdentifier();
+                    final String rootJtiToUse;
+                    if (StringSupport.trimOrNull(rootJti) == null) {
+                        log.warn("{} No root token identifier returned, using JWT id for checking revocation status",
+                                getLogPrefix());
+                        rootJtiToUse = refreshTokenClaimsSet.getID();
+                    } else {
+                        rootJtiToUse = rootJti;
+                    }
+                    if (revocationCache.isRevoked(RevocationCacheContexts.AUTHORIZATION_CODE, rootJtiToUse)) {
                         log.error("{} Authz code {} and all derived tokens have been revoked", getLogPrefix(),
-                                refreshTokenClaimsSet.getID());
+                                rootJtiToUse);
+                        ActionSupport.buildEvent(profileRequestContext, OidcEventIds.INVALID_GRANT);
+                        return;
+                    } else if (revocationCache.isRevoked(RevocationCacheContexts.SINGLE_ACCESS_OR_REFRESH_TOKENS,
+                            refreshTokenClaimsSet.getID())) {
+                        log.error("{} The refresh token {} has been revoked. Revoking the full chain now.",
+                                getLogPrefix(), refreshTokenClaimsSet.getID());
+                        final Duration lifetime =
+                                tokenRevocationLifetimeLookupStrategy.apply(profileRequestContext);
+                        if (lifetime == null) {
+                            log.error("{} Could not resolve the token revocation lifetime, full chain not revoked",
+                                    getLogPrefix());
+                            ActionSupport.buildEvent(profileRequestContext, IdPEventIds.INVALID_PROFILE_CONFIG);
+                            return;
+                        }
+                        if (!revocationCache.revoke(RevocationCacheContexts.AUTHORIZATION_CODE, rootJtiToUse,
+                                lifetime)) {
+                            log.error("{} Fatal error, unable to store revocation into the revocation cache",
+                                    getLogPrefix());
+                            ActionSupport.buildEvent(profileRequestContext, IdPEventIds.INVALID_PROFILE_CONFIG);
+                            return;
+                        }
                         ActionSupport.buildEvent(profileRequestContext, OidcEventIds.INVALID_GRANT);
                         return;
                     }
diff --git a/idp-oidc-extension-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oidc/token/token-beans.xml b/idp-oidc-extension-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oidc/token/token-beans.xml
index 2a3c1f32..5b042d44 100644
--- a/idp-oidc-extension-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oidc/token/token-beans.xml
+++ b/idp-oidc-extension-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oidc/token/token-beans.xml
@@ -318,7 +318,9 @@
 
     <bean id="SetRefreshTokenToResponseContext"
             class="net.shibboleth.idp.plugin.oidc.op.profile.impl.SetRefreshTokenToResponseContext" scope="prototype"
-            c:sealer-ref="#{'%{idp.oidc.tokenSealer:shibboleth.oidc.TokenSealer}'.trim()}">
+            c:sealer-ref="#{'%{idp.oidc.tokenSealer:shibboleth.oidc.TokenSealer}'.trim()}"
+            p:revocationCache-ref="shibboleth.oidc.RevocationCache">
+            
         <property name="activationCondition">
             <bean parent="shibboleth.Conditions.AND">
                 <constructor-arg>
diff --git a/idp-oidc-extension-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/TokenFlowTest.java b/idp-oidc-extension-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/TokenFlowTest.java
index c2b4edd1..8aa9ace5 100644
--- a/idp-oidc-extension-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/TokenFlowTest.java
+++ b/idp-oidc-extension-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/TokenFlowTest.java
@@ -19,10 +19,12 @@ package net.shibboleth.idp.plugin.oidc.op.profile.flow;
 
 import java.io.IOException;
 import java.text.ParseException;
+import java.time.Duration;
 import java.time.Instant;
 import java.util.HashMap;
 import java.util.Map;
 
+import org.opensaml.storage.RevocationCache;
 import org.opensaml.storage.StorageService;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Qualifier;
@@ -51,6 +53,7 @@ import com.nimbusds.openid.connect.sdk.claims.ClaimsSet;
 
 import net.minidev.json.JSONObject;
 import net.shibboleth.idp.plugin.oidc.op.profile.impl.ValidateGrantTest;
+import net.shibboleth.idp.plugin.oidc.op.storage.RevocationCacheContexts;
 import net.shibboleth.idp.plugin.oidc.op.token.support.AccessTokenClaimsSet;
 import net.shibboleth.idp.plugin.oidc.op.token.support.RefreshTokenClaimsSet;
 import net.shibboleth.idp.plugin.oidc.op.token.support.TokenClaimsSet;
@@ -72,6 +75,7 @@ public class TokenFlowTest extends AbstractOidcClientAuthenticationFlowTest {
     String clientIdPkcePlainUnforcedPublic = "mockPublicClientIdPKCEPlainUnforced";
     String clientIdPkceS256Public = "mockPublicClientIdPKCES256";
     String clientIdCustomTokens = "mockClientIdCustomTokens";
+    String clientIdRefreshTokenRotation = "mockClientIdRefreshTokenRotation";
     String codeVerifier = "9234567812345678123456781234567812345678123456781234567812345678";
 
     Scope scope = Scope.parse("openid profile email offline_access");
@@ -80,6 +84,10 @@ public class TokenFlowTest extends AbstractOidcClientAuthenticationFlowTest {
     @Qualifier("shibboleth.StorageService")
     StorageService storageService;
     
+    @Autowired
+    @Qualifier("shibboleth.oidc.RevocationCache")
+    RevocationCache revocationCache;
+    
     public TokenFlowTest() {
         super(FLOW_ID);
     }
@@ -90,6 +98,7 @@ public class TokenFlowTest extends AbstractOidcClientAuthenticationFlowTest {
         removeMetadata(storageService, clientIdPkcePlain);
         removeMetadata(storageService, clientIdPkceS256);
         removeMetadata(storageService, clientIdCustomTokens);
+        removeMetadata(storageService, clientIdRefreshTokenRotation);
     }
     
     @Test
@@ -354,6 +363,18 @@ public class TokenFlowTest extends AbstractOidcClientAuthenticationFlowTest {
         return new RefreshToken(getDataSealer().wrap(json, Instant.now().plusSeconds(30))).getValue();
     }
 
+    protected String buildRefreshToken(final String clientId, final String id, final String rootId,
+            final String... consentedClaims) throws Exception {
+        final TokenClaimsSet acClaims = ValidateGrantTest.buildTokenClaimsSet(clientId, "https://op.example.org", "jdoe", "mock", redirectUri,
+                null, null, null, null, scope.toString());
+        final RefreshTokenClaimsSet rtClaims = new RefreshTokenClaimsSet.Builder(acClaims, Instant.now(),
+                Instant.now().plus(Duration.ofHours(1)))
+                .setRootTokenIdentifier(rootId)
+                .setJWTID(id)
+                .build();
+        return new RefreshToken(rtClaims.serialize(new ValidateGrantTest().getDataSealer())).getValue();
+    }
+
     @Test
     public void testValidSecretJWT() throws Exception {
         final ClientSecretJWT clientAuth = buildSecretJwtAuth(clientSecret);
@@ -570,7 +591,52 @@ public class TokenFlowTest extends AbstractOidcClientAuthenticationFlowTest {
         Assert.assertNotNull(response.getOIDCTokens().getIDToken());
         validateConsentFromAccessToken(response, true);
     }
-    
+
+    @Test
+    public void testValidRefreshTokenGrant() throws Exception {
+        final String id = idGenerator.generateIdentifier();
+        final String rootId = idGenerator.generateIdentifier();
+        initializeGrantAndRequest(clientId, createRequestParameters(redirectUri, "refresh_token",
+                buildRefreshToken(clientId, id, rootId), clientId));
+        final FlowExecutionResult result = flowExecutor.launchExecution(FLOW_ID, null, externalContext);
+        final OIDCTokenResponse response = parseSuccessResponse(result, OIDCTokenResponse.class);
+        final AccessToken accessToken = response.getTokens().getAccessToken();
+        Assert.assertNotNull(accessToken);
+        Assert.assertNotNull(response.getOIDCTokens().getIDToken());
+        validateConsentFromAccessToken(response, false);
+        Assert.assertFalse(revocationCache.isRevoked(RevocationCacheContexts.SINGLE_ACCESS_OR_REFRESH_TOKENS, id));
+        Assert.assertFalse(revocationCache.isRevoked(RevocationCacheContexts.AUTHORIZATION_CODE, rootId));
+    }
+
+    @Test
+    public void testValidRefreshTokenGrantRefreshTokenRotation() throws Exception {
+        final String id = idGenerator.generateIdentifier();
+        final String rootId = idGenerator.generateIdentifier();
+        initializeGrantAndRequest(clientIdRefreshTokenRotation, createRequestParameters(redirectUri, "refresh_token",
+                buildRefreshToken(clientIdRefreshTokenRotation, id, rootId), clientIdRefreshTokenRotation));
+        final FlowExecutionResult result = flowExecutor.launchExecution(FLOW_ID, null, externalContext);
+        final OIDCTokenResponse response = parseSuccessResponse(result, OIDCTokenResponse.class);
+        final AccessToken accessToken = response.getTokens().getAccessToken();
+        Assert.assertNotNull(accessToken);
+        Assert.assertNotNull(response.getOIDCTokens().getIDToken());
+        validateConsentFromAccessToken(response, false);
+        Assert.assertTrue(revocationCache.isRevoked(RevocationCacheContexts.SINGLE_ACCESS_OR_REFRESH_TOKENS, id));
+        Assert.assertFalse(revocationCache.isRevoked(RevocationCacheContexts.AUTHORIZATION_CODE, rootId));
+    }
+
+    @Test
+    public void testRevokedRefreshTokenGrantRefreshTokenRotation() throws Exception {
+        final String id = idGenerator.generateIdentifier();
+        final String rootId = idGenerator.generateIdentifier();
+        Assert.assertTrue(revocationCache.revoke(RevocationCacheContexts.SINGLE_ACCESS_OR_REFRESH_TOKENS, id));
+        initializeGrantAndRequest(clientIdRefreshTokenRotation, createRequestParameters(redirectUri, "refresh_token",
+                buildRefreshToken(clientIdRefreshTokenRotation, id, rootId), clientIdRefreshTokenRotation));
+        final FlowExecutionResult result = flowExecutor.launchExecution(FLOW_ID, null, externalContext);
+        assertErrorCode(result, OAuth2Error.INVALID_GRANT_CODE);
+        Assert.assertTrue(revocationCache.isRevoked(RevocationCacheContexts.SINGLE_ACCESS_OR_REFRESH_TOKENS, id));
+        Assert.assertTrue(revocationCache.isRevoked(RevocationCacheContexts.AUTHORIZATION_CODE, rootId));
+    }
+
     private AccessTokenClaimsSet unwrapAccessToken(final OIDCTokenResponse tokenResponse) {
         final AccessToken accessToken = tokenResponse.getTokens().getAccessToken();
         Assert.assertNotNull(accessToken);
diff --git a/idp-oidc-extension-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/impl/SetRefreshTokenToResponseContextTest.java b/idp-oidc-extension-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/impl/SetRefreshTokenToResponseContextTest.java
index 329f22e8..fe5468f2 100644
--- a/idp-oidc-extension-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/impl/SetRefreshTokenToResponseContextTest.java
+++ b/idp-oidc-extension-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/impl/SetRefreshTokenToResponseContextTest.java
@@ -17,6 +17,7 @@
 
 package net.shibboleth.idp.plugin.oidc.op.profile.impl;
 
+import net.shibboleth.idp.plugin.oidc.op.storage.RevocationCacheContexts;
 import net.shibboleth.idp.plugin.oidc.op.token.support.AccessTokenClaimsSet;
 import net.shibboleth.idp.plugin.oidc.op.token.support.AuthorizeCodeClaimsSet;
 import net.shibboleth.idp.plugin.oidc.op.token.support.RefreshTokenClaimsSet;
@@ -35,6 +36,8 @@ import java.text.ParseException;
 import java.time.Instant;
 
 import org.opensaml.profile.action.EventIds;
+import org.opensaml.storage.RevocationCache;
+import org.opensaml.storage.impl.MemoryStorageService;
 import org.springframework.webflow.execution.Event;
 import org.testng.Assert;
 import org.testng.annotations.BeforeMethod;
@@ -49,6 +52,10 @@ public class SetRefreshTokenToResponseContextTest extends BaseOIDCResponseAction
 
     private SetRefreshTokenToResponseContext action;
 
+    RevocationCache revocationCache;
+
+    private boolean enforceRotation;
+
     @BeforeMethod
     private void init() throws ComponentInitializationException, NoSuchAlgorithmException, URISyntaxException {
         final Scope scope = new Scope();
@@ -69,6 +76,14 @@ public class SetRefreshTokenToResponseContextTest extends BaseOIDCResponseAction
                 .build();
         respCtx.setAuthorizationGrantClaimsSet(claims);
         action = new SetRefreshTokenToResponseContext(getDataSealer());
+        final MemoryStorageService storageService = new MemoryStorageService();
+        storageService.setId("id");
+        storageService.initialize();
+        revocationCache = new RevocationCache();
+        revocationCache.setStorage(storageService);
+        action.setRevocationCache(revocationCache);
+        enforceRotation = false;
+        action.setEnforceRefreshTokenRotationCondition(prc -> enforceRotation);
         action.initialize();
     }
 
@@ -123,6 +138,39 @@ public class SetRefreshTokenToResponseContextTest extends BaseOIDCResponseAction
         Assert.assertNotNull(rt);
         Assert.assertNotEquals(rt.getID(), jit);
         Assert.assertEquals(rt.getRootTokenIdentifier(), rootTokenId);
+        Assert.assertFalse(revocationCache.isRevoked(RevocationCacheContexts.SINGLE_ACCESS_OR_REFRESH_TOKENS, jit));
+    }
+
+    @Test
+    public void testSuccessViaRefreshRotationEnforced() throws ComponentInitializationException, NoSuchAlgorithmException, URISyntaxException,
+            ParseException, DataSealerException {
+        final String rootTokenId = new SecureRandomIdentifierGenerationStrategy().generateIdentifier();
+        final TokenClaimsSet claims = new RefreshTokenClaimsSet.Builder()
+                .setJWTID(idGenerator)
+                .setClientID(new ClientID())
+                .setIssuer("issuer")
+                .setPrincipal("userPrin")
+                .setSubject("subject")
+                .setIssuedAt(Instant.now())
+                .setExpiresAt(Instant.now())
+                .setAuthenticationTime(Instant.now())
+                .setRedirectURI(new URI("http://example.com"))
+                .setScope(new Scope())
+                .setACR(new ACR("0"))
+                .setRootTokenIdentifier(rootTokenId)
+                .build();
+        final String jit = claims.getID();
+        respCtx.setAuthorizationGrantClaimsSet(claims);
+        enforceRotation = true;
+        final Event event = action.execute(requestCtx);
+        ActionTestingSupport.assertProceedEvent(event);
+        Assert.assertNotNull(respCtx.getRefreshToken());
+        final RefreshTokenClaimsSet rt =
+                RefreshTokenClaimsSet.parse(respCtx.getRefreshToken().getValue(), getDataSealer());
+        Assert.assertNotNull(rt);
+        Assert.assertNotEquals(rt.getID(), jit);
+        Assert.assertEquals(rt.getRootTokenIdentifier(), rootTokenId);
+        Assert.assertTrue(revocationCache.isRevoked(RevocationCacheContexts.SINGLE_ACCESS_OR_REFRESH_TOKENS, jit));
     }
 
     /**
diff --git a/idp-oidc-extension-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/impl/ValidateGrantTest.java b/idp-oidc-extension-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/impl/ValidateGrantTest.java
index 8574ab6e..6fd39c12 100644
--- a/idp-oidc-extension-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/impl/ValidateGrantTest.java
+++ b/idp-oidc-extension-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/impl/ValidateGrantTest.java
@@ -19,9 +19,11 @@ package net.shibboleth.idp.plugin.oidc.op.profile.impl;
 
 import net.minidev.json.JSONObject;
 import net.shibboleth.idp.plugin.oidc.op.messaging.context.OIDCAuthenticationResponseContext;
+import net.shibboleth.idp.plugin.oidc.op.storage.RevocationCacheContexts;
 import net.shibboleth.idp.plugin.oidc.op.token.support.AuthorizeCodeClaimsSet;
 import net.shibboleth.idp.plugin.oidc.op.token.support.RefreshTokenClaimsSet;
 import net.shibboleth.idp.plugin.oidc.op.token.support.TokenClaimsSet;
+import net.shibboleth.idp.profile.IdPEventIds;
 import net.shibboleth.idp.profile.testing.ActionTestingSupport;
 import net.shibboleth.oidc.profile.core.OidcEventIds;
 import net.shibboleth.utilities.java.support.component.ComponentInitializationException;
@@ -30,11 +32,16 @@ import net.shibboleth.utilities.java.support.security.impl.SecureRandomIdentifie
 
 import java.net.URI;
 import java.security.NoSuchAlgorithmException;
+import java.time.Duration;
 import java.time.Instant;
+import java.util.function.Function;
 
+import org.opensaml.profile.context.ProfileRequestContext;
 import org.opensaml.storage.ReplayCache;
+import org.opensaml.storage.RevocationCache;
 import org.opensaml.storage.impl.MemoryStorageService;
 import org.testng.Assert;
+import org.testng.annotations.BeforeMethod;
 import org.testng.annotations.Test;
 
 import com.google.common.base.Predicates;
@@ -65,13 +72,30 @@ public class ValidateGrantTest extends BaseOIDCResponseActionTest {
     RefreshTokenGrant rfGrant;
 
     URI callback;
+    
+    MemoryStorageService storageService;
+    
+    String rootTokenId;
 
+    @BeforeMethod
+    protected void setupStorage() throws ComponentInitializationException {
+        storageService = new MemoryStorageService();
+        storageService.setId("id");
+        storageService.initialize();
+    }
+    
     private void init() throws Exception {
         init(true);
     }
-    
+
     private void init(boolean refreshTokensEnabled) throws Exception {
+        init(refreshTokensEnabled, new MockRevocationCache(false, true), null);
+    }
+
+    private void init(boolean refreshTokensEnabled, final RevocationCache revocationCache,
+            final Function<ProfileRequestContext, Duration> revocationLifetimeLookup) throws Exception {
         final Instant now = Instant.now();
+        rootTokenId = "mockId" + now.toEpochMilli();
         acClaims = new AuthorizeCodeClaimsSet.Builder()
                 .setJWTID(idGenerator)
                 .setClientID(new ClientID(clientId))
@@ -85,7 +109,9 @@ public class ValidateGrantTest extends BaseOIDCResponseActionTest {
                 .setScope(new Scope())
                 .build();
         
-        rfClaims = new RefreshTokenClaimsSet.Builder(acClaims, now, now.plusSeconds(100)).build();
+        rfClaims = new RefreshTokenClaimsSet.Builder(acClaims, now, now.plusSeconds(100))
+                .setRootTokenIdentifier(rootTokenId)
+                .build();
         final AuthorizationCode code = new AuthorizationCode(acClaims.serialize(getDataSealer()));
         final RefreshToken rfToken = new RefreshToken(rfClaims.serialize(getDataSealer()));
         callback = new URI("https://client.com/callback");
@@ -95,11 +121,11 @@ public class ValidateGrantTest extends BaseOIDCResponseActionTest {
         final TokenRequest req = new TokenRequest(callback, new ClientID(clientId), codeGrant);
         profileRequestCtx.getInboundMessageContext().setMessage(req);
         action = new ValidateGrant(getDataSealer());
-        action.setRevocationCache(new MockRevocationCache(false, true));
+        if (revocationLifetimeLookup != null) {
+            action.setTokenRevocationLifetimeLookupStrategy(revocationLifetimeLookup);
+        }
+        action.setRevocationCache(revocationCache);
         final ReplayCache replayCache = new ReplayCache();
-        final MemoryStorageService storageService = new MemoryStorageService();
-        storageService.setId("id");
-        storageService.initialize();
         replayCache.setStorage(storageService);
         action.setReplayCache(replayCache);
         if (refreshTokensEnabled) {
@@ -131,8 +157,16 @@ public class ValidateGrantTest extends BaseOIDCResponseActionTest {
             final String userPrincipal, final String sub, final String callbackUrl, final String codeChallenge,
             final JSONObject deliveryClaims, final JSONObject deliveryClaimsIDToken,
             final JSONObject deliveryClaimsUserInfo, final String scope) throws Exception {
+        final TokenClaimsSet acClaims = buildTokenClaimsSet(clientId, issuer, userPrincipal, sub, callbackUrl,
+                codeChallenge, deliveryClaims, deliveryClaimsIDToken, deliveryClaimsUserInfo, scope);
+        return new AuthorizationCode(acClaims.serialize(new ValidateGrantTest().getDataSealer()));
+    }
+
+    static public TokenClaimsSet buildTokenClaimsSet(final String clientId, final String issuer,
+            final String userPrincipal, final String sub, final String callbackUrl, final String codeChallenge,
+            final JSONObject deliveryClaims, final JSONObject deliveryClaimsIDToken,
+            final JSONObject deliveryClaimsUserInfo, final String scope) throws Exception {
         final Instant now = Instant.now();
-        final ValidateGrantTest test = new ValidateGrantTest();
         final AuthorizeCodeClaimsSet.Builder builder = new AuthorizeCodeClaimsSet.Builder();
         builder.setJWTID(new SecureRandomIdentifierGenerationStrategy())
             .setClientID(new ClientID(clientId))
@@ -157,10 +191,9 @@ public class ValidateGrantTest extends BaseOIDCResponseActionTest {
         if (deliveryClaimsUserInfo != null) {
             builder.setDlClaimsUI(new DeliveryClaimsSet(deliveryClaimsUserInfo));
         }
-        final TokenClaimsSet acClaims = builder.build();
-        return new AuthorizationCode(acClaims.serialize(test.getDataSealer()));
+        return builder.build();
     }
-    
+
     @Test
     public void testAuthorizeCodeSuccess() throws Exception {
         init();
@@ -211,6 +244,53 @@ public class ValidateGrantTest extends BaseOIDCResponseActionTest {
         Assert.assertNotNull(arc.getAuthorizationGrantClaimsSet());
     }
 
+    @Test
+    public void testRefreshTokenAuthorizationGrantRevoked() throws Exception {
+        final RevocationCache revocationCache = new RevocationCache();
+        revocationCache.setStorage(storageService);
+        init(true, revocationCache, null);
+        Assert.assertTrue(revocationCache.revoke(RevocationCacheContexts.AUTHORIZATION_CODE,
+                rfClaims.getRootTokenIdentifier()));
+        final TokenRequest req = new TokenRequest(callback, new ClientID(clientId), rfGrant);
+        profileRequestCtx.getInboundMessageContext().setMessage(req);
+        ActionTestingSupport.assertEvent(action.execute(requestCtx), OidcEventIds.INVALID_GRANT);
+        final OIDCAuthenticationResponseContext arc =
+                profileRequestCtx.getOutboundMessageContext().getSubcontext(OIDCAuthenticationResponseContext.class);
+        Assert.assertNull(arc.getAuthorizationGrantClaimsSet());
+    }
+
+    @Test
+    public void testRefreshTokenRevokedShouldRevokeAuthorizationCode() throws Exception {
+        final RevocationCache revocationCache = new RevocationCache();
+        revocationCache.setStorage(storageService);
+        init(true, revocationCache, null);
+        Assert.assertTrue(revocationCache.revoke(RevocationCacheContexts.SINGLE_ACCESS_OR_REFRESH_TOKENS,
+                rfClaims.getID()));
+        final TokenRequest req = new TokenRequest(callback, new ClientID(clientId), rfGrant);
+        profileRequestCtx.getInboundMessageContext().setMessage(req);
+        ActionTestingSupport.assertEvent(action.execute(requestCtx), OidcEventIds.INVALID_GRANT);
+        final OIDCAuthenticationResponseContext arc =
+                profileRequestCtx.getOutboundMessageContext().getSubcontext(OIDCAuthenticationResponseContext.class);
+        Assert.assertNull(arc.getAuthorizationGrantClaimsSet());
+        Assert.assertTrue(revocationCache.isRevoked(RevocationCacheContexts.AUTHORIZATION_CODE,
+                rfClaims.getRootTokenIdentifier()));
+    }
+
+    @Test
+    public void testRefreshTokenRevokedLifetimeRequired() throws Exception {
+        final RevocationCache revocationCache = new RevocationCache();
+        revocationCache.setStorage(storageService);
+        init(true, revocationCache, prc -> null);
+        Assert.assertTrue(revocationCache.revoke(RevocationCacheContexts.SINGLE_ACCESS_OR_REFRESH_TOKENS,
+                rfClaims.getID()));
+        final TokenRequest req = new TokenRequest(callback, new ClientID(clientId), rfGrant);
+        profileRequestCtx.getInboundMessageContext().setMessage(req);
+        ActionTestingSupport.assertEvent(action.execute(requestCtx), IdPEventIds.INVALID_PROFILE_CONFIG);
+        final OIDCAuthenticationResponseContext arc =
+                profileRequestCtx.getOutboundMessageContext().getSubcontext(OIDCAuthenticationResponseContext.class);
+        Assert.assertNull(arc.getAuthorizationGrantClaimsSet());
+    }
+
     @Test
     public void testMixGrant() throws Exception {
         init();
diff --git a/idp-oidc-extension-impl/src/test/resources/conf/relying-party.xml b/idp-oidc-extension-impl/src/test/resources/conf/relying-party.xml
index e6da1687..7b4a256c 100644
--- a/idp-oidc-extension-impl/src/test/resources/conf/relying-party.xml
+++ b/idp-oidc-extension-impl/src/test/resources/conf/relying-party.xml
@@ -58,6 +58,14 @@
     </bean>
 
     <util:list id="shibboleth.RelyingPartyOverrides">
+        <bean parent="RelyingPartyByName" c:relyingPartyIds="mockClientIdRefreshTokenRotation">
+            <property name="profileConfigurations">
+                 <list>
+                     <ref bean="OIDC.SSO.MDDriven" />
+                     <bean parent="OAUTH2.Token.MDDriven" p:enforceRefreshTokenRotation="true "/>
+                 </list>
+            </property>
+        </bean>
         <bean parent="RelyingPartyByName" c:relyingPartyIds="mockClientIdCustomTokens">
             <property name="profileConfigurations">
                  <list>

-- 
To stop receiving notification emails like this one, please contact
the administrator of this repository.


More information about the commits mailing list