[java-idp-oidc] branch main updated: JOIDC-186 - Support additional refresh token types

Henri Mikkonen henri.mikkonen at iki.fi
Fri Jan 19 06:12:14 UTC 2024


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=bc022b4fd326e0a62f425a00764bd4666b0b70a0

The following commit(s) were added to refs/heads/main by this push:
     new bc022b4f JOIDC-186 - Support additional refresh token types
bc022b4f is described below

commit bc022b4fd326e0a62f425a00764bd4666b0b70a0
Author: Henri Mikkonen <henri.mikkonen at iki.fi>
AuthorDate: Fri Jan 19 08:11:28 2024 +0200

    JOIDC-186 - Support additional refresh token types
    
    https://shibboleth.atlassian.net/browse/JOIDC-186
    
    Two new beans wired to the actions dealing with refresh tokens
    
    shibboleth.oidc.DefaultRefreshTokenSerializationStrategies (in token beans)
    - Map of serialization strategies (keyed with the refresh token type)
     - Serializer: BiFunction<ProfileRequestContext,RefreshTokenClaimsSet,String>
    
    shibboleth.oidc.DefaultRefreshTokenDeserializers (in abstract beans)
    - List of deserializers
      - Deserializer: BiFunction<ProfileRequestContext,String,RefreshTokenClaimsSet>
    - Used by token, introspection and revocation flow
---
 .../profile/impl/AbstractProcessTokenAction.java   | 111 +++++----
 .../impl/SetRefreshTokenToResponseContext.java     |  89 ++++++--
 .../plugin/oidc/op/profile/impl/ValidateGrant.java | 117 ++++++----
 ...aultJwtRefreshTokenDeserializationFunction.java | 220 ++++++++++++++++++
 ...efaultJwtRefreshTokenSerializationFunction.java | 251 +++++++++++++++++++++
 .../oauth2/introspection/introspection-beans.xml   |   3 +-
 .../flows/oauth2/revocation/revocation-beans.xml   |   3 +-
 .../oidc/abstract-api/oidc-abstract-api-beans.xml  |  49 ++++
 .../idp/flows/oidc/token/token-beans.xml           |  56 ++++-
 .../op/profile/flow/AbstractOidcApiFlowTest.java   |  26 +++
 .../op/profile/flow/IntrospectionFlowTest.java     |  41 ++++
 .../oidc/op/profile/flow/RevocationFlowTest.java   |  32 +++
 .../plugin/oidc/op/profile/flow/TokenFlowTest.java | 108 +++++++--
 .../impl/SetRefreshTokenToResponseContextTest.java |  80 +++++++
 .../oidc/op/profile/impl/ValidateGrantTest.java    |  51 ++++-
 ...JwtRefreshTokenDeserializationFunctionTest.java | 205 +++++++++++++++++
 ...ltJwtRefreshTokenSerializationFunctionTest.java | 205 +++++++++++++++++
 .../shibboleth/idp/module/conf/relying-party.xml   |  11 +
 18 files changed, 1537 insertions(+), 121 deletions(-)

diff --git a/idp-oidc-extension-impl/src/main/java/net/shibboleth/idp/plugin/oidc/op/oauth2/profile/impl/AbstractProcessTokenAction.java b/idp-oidc-extension-impl/src/main/java/net/shibboleth/idp/plugin/oidc/op/oauth2/profile/impl/AbstractProcessTokenAction.java
index 1af9a2b1..e56f8692 100644
--- a/idp-oidc-extension-impl/src/main/java/net/shibboleth/idp/plugin/oidc/op/oauth2/profile/impl/AbstractProcessTokenAction.java
+++ b/idp-oidc-extension-impl/src/main/java/net/shibboleth/idp/plugin/oidc/op/oauth2/profile/impl/AbstractProcessTokenAction.java
@@ -17,6 +17,8 @@ package net.shibboleth.idp.plugin.oidc.op.oauth2.profile.impl;
 import java.text.ParseException;
 import java.util.ArrayList;
 import java.util.Collection;
+import java.util.List;
+import java.util.function.BiFunction;
 import java.util.function.Function;
 
 import javax.annotation.Nonnull;
@@ -51,6 +53,7 @@ import net.shibboleth.oidc.profile.config.navigate.IssuedClaimsValidatorLookupFu
 import net.shibboleth.oidc.profile.core.OidcEventIds;
 import net.shibboleth.oidc.security.impl.JWTSignatureValidationUtil;
 import net.shibboleth.shared.annotation.constraint.NotEmpty;
+import net.shibboleth.shared.collection.CollectionSupport;
 import net.shibboleth.shared.logic.Constraint;
 import net.shibboleth.shared.resolver.CriteriaSet;
 import net.shibboleth.shared.resolver.ResolverException;
@@ -89,12 +92,16 @@ public abstract class AbstractProcessTokenAction<T> extends AbstractOIDCRequestA
     /** Source of signing keys. */
     @Nullable private CredentialResolver credentialResolver;
 
+    /** List of deserializer bi-functions for refresh tokens to be used in addition to unsealing opaque value. */
+    @Nonnull private List<BiFunction<ProfileRequestContext,String,RefreshTokenClaimsSet>> refreshTokenDeserializers;
+
     /** Copy of signed JWT for non-opaque access tokens. */
     @Nullable private SignedJWT signedJWT;
 
     /** Constructor. */
     public AbstractProcessTokenAction() {
         claimsValidatorLookupStrategy = new IssuedClaimsValidatorLookupFunction();
+        refreshTokenDeserializers = CollectionSupport.emptyList();
     }
     
     /**
@@ -128,6 +135,18 @@ public abstract class AbstractProcessTokenAction<T> extends AbstractOIDCRequestA
         credentialResolver = resolver;
     }
 
+    /**
+     * Set the list of deserializer bi-functions for refresh tokens to be used in addition to unsealing opaque value.
+     * 
+     * @param deserializers list of deserializers
+     */
+    public void setRefreshTokenDeserializers(
+            @Nonnull final List<BiFunction<ProfileRequestContext,String,RefreshTokenClaimsSet>> deserializers) {
+        checkSetterPreconditions();
+        refreshTokenDeserializers =
+                Constraint.isNotNull(deserializers, "List of refresh token deserializers cannot be null");
+    }
+
     /** {@inheritDoc} */
     @Override
     protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
@@ -162,12 +181,12 @@ public abstract class AbstractProcessTokenAction<T> extends AbstractOIDCRequestA
         if (token instanceof AccessToken) {
             tokenClaimsSet = parseAccessToken(token);
         } else if (token instanceof RefreshToken) {
-            tokenClaimsSet = parseRefreshToken(token);
+            tokenClaimsSet = parseRefreshToken(profileRequestContext, token);
         } else {
             // No token hint, have to try both.
             tokenClaimsSet = parseAccessToken(token);
             if (tokenClaimsSet == null) {
-                tokenClaimsSet = parseRefreshToken(token);
+                tokenClaimsSet = parseRefreshToken(profileRequestContext, token);
             }
         }
         
@@ -176,40 +195,6 @@ public abstract class AbstractProcessTokenAction<T> extends AbstractOIDCRequestA
             return;
         }
 
-        if (signedJWT != null) {
-            // Check typ header.
-            final JOSEObjectType typ = signedJWT.getHeader().getType();
-            if (typ == null || !"at+jwt".equals(typ.getType())) {
-                log.warn("{} Missing or invalid token type: {}", getLogPrefix(), typ != null ? typ.getType() : "null");
-                return;
-            }
-            
-            if (credentialResolver == null) {
-                log.error("{} No CredentialResolver available, can't verify JWT signature", getLogPrefix());
-                return;
-            }
-            
-            log.debug("{} Checking JWT signature", getLogPrefix());
-            final Collection<Credential> credList = new ArrayList<>();
-            final CriteriaSet criteriaSet = new CriteriaSet(new UsageCriterion(UsageType.SIGNING));
-            try {
-                final Iterable<Credential> creds = credentialResolver.resolve(criteriaSet);
-                if (creds != null) {
-                    creds.forEach(credList::add);
-                }
-            } catch (final ResolverException e) {
-                log.error("{} Failure resolving signing credentials, can't verify JWT signature", getLogPrefix(), e);
-                return;
-            }
-
-            final String errorEventId = JWTSignatureValidationUtil.validateSignatureEx(credList, signedJWT,
-                    OidcEventIds.INVALID_GRANT);
-            if (errorEventId != null) {
-                log.warn("{} Signature on token ID '{}' invalid", getLogPrefix(), tokenClaimsSet.getJWTID());
-                return;
-            }
-        }
-
         log.debug("{} Validating parsed/decoded claims set: {}", getLogPrefix(), tokenClaimsSet.toString());
         try {
             claimsValidator.validate(tokenClaimsSet, profileRequestContext);
@@ -236,7 +221,39 @@ public abstract class AbstractProcessTokenAction<T> extends AbstractOIDCRequestA
         // Try parsing as a JWT.
         try {
             signedJWT = SignedJWT.parse(token.getValue());
-            return signedJWT.getJWTClaimsSet();
+            if (signedJWT != null) {
+                // Check typ header.
+                final JOSEObjectType typ = signedJWT.getHeader().getType();
+                if (typ != null && "at+jwt".equals(typ.getType())) {
+                    if (credentialResolver == null) {
+                        log.error("{} No CredentialResolver available, can't verify JWT signature", getLogPrefix());
+                        return null;
+                    }
+
+                    final JWTClaimsSet jwtClaimsSet = signedJWT.getJWTClaimsSet();
+                    log.debug("{} Checking JWT signature", getLogPrefix());
+                    final Collection<Credential> credList = new ArrayList<>();
+                    final CriteriaSet criteriaSet = new CriteriaSet(new UsageCriterion(UsageType.SIGNING));
+                    try {
+                        final Iterable<Credential> creds = credentialResolver.resolve(criteriaSet);
+                        creds.forEach(credList::add);
+                    } catch (final ResolverException e) {
+                        log.error("{} Failure resolving signing credentials, can't verify JWT signature", getLogPrefix(), e);
+                        return null;
+                    }
+
+                    final String errorEventId = JWTSignatureValidationUtil.validateSignatureEx(credList, signedJWT,
+                            OidcEventIds.INVALID_GRANT);
+                    if (errorEventId != null) {
+                        log.warn("{} Signature on token ID '{}' invalid", getLogPrefix(), jwtClaimsSet.getJWTID());
+                        return null;
+                    } 
+                    return jwtClaimsSet;
+                } else {
+                    log.warn("{} Missing or invalid token type: {}", getLogPrefix(), typ != null ? typ.getType() : "null");
+                    return null;
+                }
+            }
         } catch (final ParseException e1) {
             
         }
@@ -258,15 +275,21 @@ public abstract class AbstractProcessTokenAction<T> extends AbstractOIDCRequestA
      * 
      * @return parsed claim set or null
      */
-    @Nullable protected JWTClaimsSet parseRefreshToken(@Nonnull @NotEmpty final Token token) {
-
-        // All refresh tokens are opaque.
+    @Nullable protected JWTClaimsSet parseRefreshToken(@Nonnull final ProfileRequestContext profileRequestContext,
+            @Nonnull @NotEmpty final Token token) {
+        final String refreshToken = token.getValue();
         try {
-            return RefreshTokenClaimsSet.parse(token.getValue(), dataSealer).getClaimsSet();
-        } catch (final DataSealerException | ParseException e) {
-            
+            assert dataSealer != null;
+            return RefreshTokenClaimsSet.parse(refreshToken, dataSealer).getClaimsSet();
+        } catch (ParseException | DataSealerException e) {
+        }
+        for (final BiFunction<ProfileRequestContext, String, RefreshTokenClaimsSet> deserializer : 
+            refreshTokenDeserializers) {
+            final RefreshTokenClaimsSet deserializedSet = deserializer.apply(profileRequestContext, refreshToken);
+            if (deserializedSet != null) {
+                return deserializedSet.getClaimsSet();
+            }
         }
-        
         return null;
     }
 
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 0589d035..e3cf0379 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
@@ -44,8 +44,10 @@ import net.shibboleth.oidc.profile.config.logic.EnforceRefreshTokenRotationPredi
 import net.shibboleth.oidc.profile.config.navigate.RefreshTokenChainLifetimeLookupFunction;
 import net.shibboleth.oidc.profile.config.navigate.RefreshTokenClaimsSetManipulationStrategyLookupFunction;
 import net.shibboleth.oidc.profile.config.navigate.RefreshTokenTimeoutLookupFunction;
+import net.shibboleth.oidc.profile.config.navigate.RefreshTokenTypeLookupFunction;
 import net.shibboleth.shared.annotation.ParameterName;
 import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
+import net.shibboleth.shared.collection.CollectionSupport;
 import net.shibboleth.shared.component.ComponentInitializationException;
 import net.shibboleth.shared.logic.Constraint;
 import net.shibboleth.shared.logic.FunctionSupport;
@@ -97,6 +99,13 @@ public class SetRefreshTokenToResponseContext extends AbstractOIDCResponseAction
     /** Lookup function to supply token revocation lifetime. */
     @Nonnull private Function<JWTClaimsSet,Duration> tokenRevocationLifetimeLookupStrategy;
 
+    /** Strategy used to obtain the refresh token type to issue. */
+    @Nonnull private Function<ProfileRequestContext,String> refreshTokenTypeLookupStrategy;
+
+    /** The strategies used for serializing refresh token claims set, key referring to the refresh token type. */
+    @Nonnull private Map<String, BiFunction<ProfileRequestContext,RefreshTokenClaimsSet,String>>
+        refreshTokenSerializationStrategies;
+
     /** Authorize Code / Refresh Token the refresh token will be based on. */
     @Nullable private TokenClaimsSet tokenClaimsSet;
     
@@ -109,6 +118,9 @@ public class SetRefreshTokenToResponseContext extends AbstractOIDCResponseAction
     /** The generator to use. */
     @Nullable private IdentifierGenerationStrategy idGenerator;
 
+    /** Refresh Token type. */
+    @Nullable private String refreshTokenType;
+
     /**
      * Constructor.
      * 
@@ -123,6 +135,8 @@ public class SetRefreshTokenToResponseContext extends AbstractOIDCResponseAction
         idGeneratorLookupStrategy = FunctionSupport.constant(new SecureRandomIdentifierGenerationStrategy());
         enforceRefreshTokenRotationCondition = new EnforceRefreshTokenRotationPredicate();
         tokenRevocationLifetimeLookupStrategy = new DefaultTokenRevocationLifetimeLookupStrategy();
+        refreshTokenTypeLookupStrategy = new RefreshTokenTypeLookupFunction();
+        refreshTokenSerializationStrategies = CollectionSupport.emptyMap();
     }
 
     /**
@@ -131,7 +145,8 @@ public class SetRefreshTokenToResponseContext extends AbstractOIDCResponseAction
      * @param cache The revocationCache to set.
      */
     public void setRevocationCache(@Nonnull final RevocationCache cache) {
-        ifInitializedThrowUnmodifiabledComponentException();
+        checkSetterPreconditions();
+
         revocationCache = Constraint.isNotNull(cache, "RevocationCache cannot be null");
     }
 
@@ -142,7 +157,7 @@ public class SetRefreshTokenToResponseContext extends AbstractOIDCResponseAction
      */
     public void setRefreshTokenChainLifetimeLookupStrategy(
             @Nonnull final Function<ProfileRequestContext,Duration> strategy) {
-        ifInitializedThrowUnmodifiabledComponentException();
+        checkSetterPreconditions();
         
         refreshTokenChainLifetimeLookupStrategy =
                 Constraint.isNotNull(strategy, "Refresh token chain lifetime lookup strategy cannot be null");
@@ -155,7 +170,7 @@ public class SetRefreshTokenToResponseContext extends AbstractOIDCResponseAction
      */
     public void setRefreshTokenTimeoutLookupStrategy(
             @Nonnull final Function<ProfileRequestContext,Duration> strategy) {
-        ifInitializedThrowUnmodifiabledComponentException();
+        checkSetterPreconditions();
 
         refreshTokenTimeoutLookupStrategy =
                 Constraint.isNotNull(strategy, "Refresh token timeout lookup strategy cannot be null");
@@ -169,7 +184,7 @@ public class SetRefreshTokenToResponseContext extends AbstractOIDCResponseAction
     public void setTokenClaimsSetManipulationStrategyLookupStrategy(@Nonnull final
             Function<ProfileRequestContext,BiFunction<ProfileRequestContext,Map<String,Object>,Map<String,Object>>>
             strategy) {
-        ifInitializedThrowUnmodifiabledComponentException();
+        checkSetterPreconditions();
 
         tokenClaimsSetManipulationStrategyLookupStrategy =
                 Constraint.isNotNull(strategy, "Manipulation strategy lookup strategy cannot be null");
@@ -182,7 +197,7 @@ public class SetRefreshTokenToResponseContext extends AbstractOIDCResponseAction
      */
     public void setIdentifierGeneratorLookupStrategy(
             @Nonnull final Function<ProfileRequestContext,IdentifierGenerationStrategy> strategy) {
-        ifInitializedThrowUnmodifiabledComponentException();
+        checkSetterPreconditions();
 
         idGeneratorLookupStrategy =
                 Constraint.isNotNull(strategy, "Identifier generation strategy cannot be null");
@@ -194,7 +209,7 @@ public class SetRefreshTokenToResponseContext extends AbstractOIDCResponseAction
      * @param condition condition to apply
      */
     public void setEnforceRefreshTokenRotationCondition(@Nonnull final Predicate<ProfileRequestContext> condition) {
-        ifInitializedThrowUnmodifiabledComponentException();
+        checkSetterPreconditions();
 
         enforceRefreshTokenRotationCondition = Constraint.isNotNull(condition, "Condition cannot be null");
     }
@@ -206,9 +221,34 @@ public class SetRefreshTokenToResponseContext extends AbstractOIDCResponseAction
      */
     public void setTokenRevocationLifetimeLookupStrategy(
             @Nullable final Function<JWTClaimsSet,Duration> strategy) {
+        checkSetterPreconditions();
+
         tokenRevocationLifetimeLookupStrategy = Constraint.isNotNull(strategy, "Lookup strategy cannot be null");
     }
 
+    /**
+     * Set a lookup strategy to obtain the refresh token type to issue.
+     *
+     * @param strategy What to set.
+     */
+    public void setRefreshTokenTypeLookupStrategy(@Nonnull final Function<ProfileRequestContext, String> strategy) {
+        checkSetterPreconditions();
+
+        refreshTokenTypeLookupStrategy = Constraint.isNotNull(strategy, "Lookup strategy cannot be null");
+    }
+
+    /**
+     * Set the strategies used for serializing refresh token claims set, key referring to the refresh token type.
+     * 
+     * @param strategies What to set.
+     */
+    public void setRefreshTokenSerializationStrategies(@Nonnull final
+            Map<String, BiFunction<ProfileRequestContext,RefreshTokenClaimsSet,String>> strategies) {
+        checkSetterPreconditions();
+
+        refreshTokenSerializationStrategies = Constraint.isNotNull(strategies, "Strategies cannot be null");
+    }
+
     /** {@inheritDoc} */
     @Override
     protected void doInitialize() throws ComponentInitializationException {
@@ -257,6 +297,8 @@ public class SetRefreshTokenToResponseContext extends AbstractOIDCResponseAction
             return false;
         }
 
+        refreshTokenType = refreshTokenTypeLookupStrategy.apply(profileRequestContext);
+
         return true;
     }
 
@@ -299,15 +341,34 @@ public class SetRefreshTokenToResponseContext extends AbstractOIDCResponseAction
         } else {
             log.debug("{} No manipulation strategy configured", getLogPrefix());
         }
-
-        try {
-            getOidcResponseContext().setRefreshToken(claimsSet.serialize(dataSealer));
-            log.debug("{} Setting refresh token {} as {} to response context ", getLogPrefix(), claimsSet.serialize(),
-                    getOidcResponseContext().getRefreshToken());
-        } catch (final DataSealerException e) {
-            log.error("{} Refresh Token generation failed {}", getLogPrefix(), e.getMessage());
-            ActionSupport.buildEvent(profileRequestContext, EventIds.UNABLE_TO_ENCRYPT);
+        if (StringSupport.trimOrNull(refreshTokenType) == null) {
+            try {
+                getOidcResponseContext().setRefreshToken(claimsSet.serialize(dataSealer));
+            } catch (final DataSealerException e) {
+                log.error("{} Refresh Token generation failed {}", getLogPrefix(), e.getMessage());
+                ActionSupport.buildEvent(profileRequestContext, EventIds.UNABLE_TO_ENCRYPT);
+                return;
+            }
+        } else {
+            final BiFunction<ProfileRequestContext,RefreshTokenClaimsSet,String> serializationStrategy
+                  = refreshTokenSerializationStrategies.get(refreshTokenType);
+            if (serializationStrategy == null) {
+                log.error("{} Could not find a seralization strategy for refresh token type {}", getLogPrefix(),
+                        refreshTokenType);
+                ActionSupport.buildEvent(profileRequestContext, IdPEventIds.INVALID_PROFILE_CONFIG);
+                return;
+            }
+            final String serializedToken = serializationStrategy.apply(profileRequestContext, claimsSet);
+            if (serializedToken == null) {
+                log.error("{} Could not serialize the claims set with refresh token type {}", getLogPrefix(),
+                        refreshTokenType);
+                ActionSupport.buildEvent(profileRequestContext, IdPEventIds.INVALID_PROFILE_CONFIG);
+                return;
+            }
+            getOidcResponseContext().setRefreshToken(serializedToken);
         }
+        log.debug("{} Setting refresh token {} as {} to response context ", getLogPrefix(), claimsSet.serialize(),
+                getOidcResponseContext().getRefreshToken());
 
         if (enforceRefreshTokenRotationCondition.test(profileRequestContext) && 
                 tokenClaimsSet instanceof RefreshTokenClaimsSet) {
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 2f9b0141..b7ce2e87 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
@@ -17,6 +17,8 @@ package net.shibboleth.idp.plugin.oidc.op.profile.impl;
 import java.text.ParseException;
 import java.time.Duration;
 import java.time.Instant;
+import java.util.List;
+import java.util.function.BiFunction;
 import java.util.function.Function;
 import java.util.function.Predicate;
 
@@ -50,6 +52,7 @@ import net.shibboleth.oidc.profile.core.OidcEventIds;
 import net.shibboleth.profile.context.RelyingPartyContext;
 import net.shibboleth.shared.annotation.ParameterName;
 import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
+import net.shibboleth.shared.collection.CollectionSupport;
 import net.shibboleth.shared.component.ComponentInitializationException;
 import net.shibboleth.shared.logic.Constraint;
 import net.shibboleth.shared.primitive.StringSupport;
@@ -97,6 +100,9 @@ public class ValidateGrant extends AbstractOIDCTokenResponseAction {
     /** Strategy used to obtain the refresh token lifetime. */
     @Nonnull private Function<ProfileRequestContext,Duration> refreshTokenChainLifetimeLookupStrategy;
 
+    /** List of deserializer bi-functions for refresh tokens to be used in addition to unsealing opaque value. */
+    @Nonnull private List<BiFunction<ProfileRequestContext,String,RefreshTokenClaimsSet>> refreshTokenDeserializers;
+
     /** The RelyingPartyContext to operate on. */
     @Nullable private RelyingPartyContext rpCtx;
 
@@ -115,6 +121,7 @@ public class ValidateGrant extends AbstractOIDCTokenResponseAction {
         chainRevocationLifetimeLookupStrategy = new DefaultChainRevocationLifetimeLookupStrategy();
         ((RevocationLifetimeLookupFunction) chainRevocationLifetimeLookupStrategy).setUseActiveProfileOnly(false);
         refreshTokenChainLifetimeLookupStrategy = new RefreshTokenChainLifetimeLookupFunction();
+        refreshTokenDeserializers = CollectionSupport.emptyList();
     }
 
     /**
@@ -186,6 +193,18 @@ public class ValidateGrant extends AbstractOIDCTokenResponseAction {
                 Constraint.isNotNull(strategy, "Refresh token chain lifetime lookup strategy cannot be null");
     }
 
+    /**
+     * Set the list of deserializer bi-functions for refresh tokens to be used in addition to unsealing opaque value.
+     * 
+     * @param deserializers list of deserializers
+     */
+    public void setRefreshTokenDeserializers(
+            @Nonnull final List<BiFunction<ProfileRequestContext,String,RefreshTokenClaimsSet>> deserializers) {
+        checkSetterPreconditions();
+        refreshTokenDeserializers =
+                Constraint.isNotNull(deserializers, "List of refresh token deserializers cannot be null");
+    }
+
     /** {@inheritDoc} */
     @Override
     protected void doInitialize() throws ComponentInitializationException {
@@ -264,50 +283,49 @@ public class ValidateGrant extends AbstractOIDCTokenResponseAction {
             final RefreshTokenGrant refreshTokentokenGrant = (RefreshTokenGrant) grant;
             if (refreshTokentokenGrant.getRefreshToken() != null
                     && refreshTokentokenGrant.getRefreshToken().getValue() != null) {
-                try {
-                    final RefreshTokenClaimsSet refreshTokenClaimsSet = RefreshTokenClaimsSet
-                            .parse(refreshTokentokenGrant.getRefreshToken().getValue(), dataSealer);
-                    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",
+                final RefreshTokenClaimsSet refreshTokenClaimsSet = deserializeRefreshToken(profileRequestContext,
+                        refreshTokentokenGrant.getRefreshToken().getValue());
+                if (refreshTokenClaimsSet == null) {
+                    log.warn("{} Unwrapping refresh token failed", getLogPrefix());
+                    ActionSupport.buildEvent(profileRequestContext, OidcEventIds.INVALID_GRANT);
+                    return;
+                }
+                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(),
+                            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());
+                    if (!revokeChain(rootJtiToUse,
+                            chainRevocationLifetimeLookupStrategy.apply(profileRequestContext))) {
+                        log.error("{} Fatal error, unable to store revocation into the revocation cache",
                                 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(),
-                                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());
-                        if (!revokeChain(rootJtiToUse,
-                                chainRevocationLifetimeLookupStrategy.apply(profileRequestContext))) {
-                            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);
+                        ActionSupport.buildEvent(profileRequestContext, IdPEventIds.INVALID_PROFILE_CONFIG);
                         return;
                     }
-                    if (refreshTokenClaimsSet.getChainExp() != null 
-                            && refreshTokenClaimsSet.getChainExp().isBefore(Instant.now())) {
-                        log.warn("{} Refresh token chain has expired on {}", getLogPrefix(),
-                                refreshTokenClaimsSet.getChainExp());
-                        ActionSupport.buildEvent(profileRequestContext, OidcEventIds.INVALID_GRANT);
-                        return;
-                    }
-                    tokenClaimsSet = refreshTokenClaimsSet;
-                } catch (final ParseException | DataSealerException e) {
-                    log.warn("{} Unwrapping refresh token failed {}", getLogPrefix(), e.getMessage());
                     ActionSupport.buildEvent(profileRequestContext, OidcEventIds.INVALID_GRANT);
                     return;
                 }
+                if (refreshTokenClaimsSet.getChainExp() != null 
+                        && refreshTokenClaimsSet.getChainExp().isBefore(Instant.now())) {
+                    log.warn("{} Refresh token chain has expired on {}", getLogPrefix(),
+                            refreshTokenClaimsSet.getChainExp());
+                    ActionSupport.buildEvent(profileRequestContext, OidcEventIds.INVALID_GRANT);
+                    return;
+                }
+                tokenClaimsSet = refreshTokenClaimsSet;
                 if (Instant.now().isAfter(tokenClaimsSet.getAuthenticationTime().plus(refreshTokenChainLifetime))) {
                     log.warn("{} Refresh token chain is expired, the authentication instant was {}", getLogPrefix(),
                             tokenClaimsSet.getAuthenticationTime());
@@ -340,6 +358,29 @@ public class ValidateGrant extends AbstractOIDCTokenResponseAction {
     }
 // Checkstyle: CyclomaticComplexity|MethodLength|ReturnCount ON
 
+    /**
+     * Attempt to deseriaalize a (serialized) refresh token value via configured deserializers.
+     * 
+     * @param profileRequestContext The profile request context given to the deserializers
+     * @param refreshToken The serialized refresh token value
+     * @return refresh token claims set, or null if it couldn't be parsed
+     */
+    protected RefreshTokenClaimsSet deserializeRefreshToken(@Nonnull final ProfileRequestContext profileRequestContext,
+            @Nonnull final String refreshToken) {
+        try {
+            return RefreshTokenClaimsSet.parse(refreshToken, dataSealer);
+        } catch (ParseException | DataSealerException e) {
+        }
+        for (final BiFunction<ProfileRequestContext, String, RefreshTokenClaimsSet> deserializer : 
+            refreshTokenDeserializers) {
+            final RefreshTokenClaimsSet deserializedSet = deserializer.apply(profileRequestContext, refreshToken);
+            if (deserializedSet != null) {
+                return deserializedSet;
+            }
+        }
+        return null;
+    }
+
     /**
      * Revokes the token chain with the given id, optionally with a given lifetime. If the given lifetime is null,
      * the default lifetime set to the {@link RevocationCache} is used.
diff --git a/idp-oidc-extension-impl/src/main/java/net/shibboleth/idp/plugin/oidc/op/profile/logic/DefaultJwtRefreshTokenDeserializationFunction.java b/idp-oidc-extension-impl/src/main/java/net/shibboleth/idp/plugin/oidc/op/profile/logic/DefaultJwtRefreshTokenDeserializationFunction.java
new file mode 100644
index 00000000..38761b0b
--- /dev/null
+++ b/idp-oidc-extension-impl/src/main/java/net/shibboleth/idp/plugin/oidc/op/profile/logic/DefaultJwtRefreshTokenDeserializationFunction.java
@@ -0,0 +1,220 @@
+/*
+ * Licensed 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 net.shibboleth.idp.plugin.oidc.op.profile.logic;
+
+import java.text.ParseException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.function.BiFunction;
+import java.util.function.BiPredicate;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.opensaml.security.credential.Credential;
+import org.opensaml.security.credential.CredentialResolver;
+import org.opensaml.security.credential.UsageType;
+import org.opensaml.security.criteria.UsageCriterion;
+import org.slf4j.Logger;
+
+import com.nimbusds.jose.JOSEObjectType;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.SignedJWT;
+
+import net.shibboleth.idp.plugin.oidc.op.token.support.RefreshTokenClaimsSet;
+import net.shibboleth.idp.plugin.oidc.op.token.support.TokenClaimsSet;
+import net.shibboleth.oidc.jwt.claims.ClaimsValidator;
+import net.shibboleth.oidc.jwt.claims.JWTValidationException;
+import net.shibboleth.oidc.profile.core.OidcEventIds;
+import net.shibboleth.oidc.security.impl.JWTSignatureValidationUtil;
+import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
+import net.shibboleth.shared.component.AbstractInitializableComponent;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.primitive.LoggerFactory;
+import net.shibboleth.shared.resolver.CriteriaSet;
+import net.shibboleth.shared.resolver.ResolverException;
+import net.shibboleth.shared.security.DataSealer;
+import net.shibboleth.shared.security.DataSealerException;
+
+/**
+ * Default implementation for deserializing an incoming JWT refresh token into the refresh token claims set. The JWT
+ * needs to be signed in a way that it can be validated using a credential returned by the configurable
+ * {@link CredentialResolver}. The JWT claims are validated via configurable {@link ClaimsValidator}, before the
+ * contents of the 'for_op' claim are decrypted into {@link RefreshTokenClaimsSet}.
+ */
+public class DefaultJwtRefreshTokenDeserializationFunction extends AbstractInitializableComponent
+     implements BiFunction<ProfileRequestContext,String,RefreshTokenClaimsSet> {
+
+    /** Class logger. */
+    @Nonnull private final Logger log = LoggerFactory.getLogger(DefaultJwtRefreshTokenDeserializationFunction.class);
+
+    /** The claims validator to use for validating the JWT refresh token. */
+    @NonnullAfterInit private ClaimsValidator claimsValidator;
+    
+    /** Source of signing keys used for validating the signature of the JWT refresh token. */
+    @NonnullAfterInit private CredentialResolver credentialResolver;
+
+    /** Data sealer for decrypting the private parts of the refresh token. */
+    @NonnullAfterInit private DataSealer dataSealer;
+
+    /** Strategy used to validate the incoming JWT type header value. */
+    @NonnullAfterInit private BiPredicate<ProfileRequestContext,String> typeHeaderValidationStrategy;
+
+    /**
+     * Constructor.
+     */
+    public DefaultJwtRefreshTokenDeserializationFunction() {
+    }
+
+    /**
+     * Set the data sealer instance to use.
+     * 
+     * @param sealer data sealer to use
+     */
+    public void setDataSealer(@Nonnull final DataSealer sealer) {
+        checkSetterPreconditions();
+        dataSealer = Constraint.isNotNull(sealer, "Data sealer cannot be null");
+    }
+    
+    /**
+     * Set the claims validator lookup strategy.
+     * 
+     * @param validator claims validator
+     */
+    public void setClaimsValidator(@Nonnull final ClaimsValidator validator) {
+        checkSetterPreconditions();
+        claimsValidator = Constraint.isNotNull(validator, "Claims validator cannot be null");
+    }
+    
+    /**
+     * Set the source of signing keys to use for JWT signature verification.
+     * 
+     * @param resolver signing key resolver
+     */
+    public void setCredentialResolver(@Nullable final CredentialResolver resolver) {
+        checkSetterPreconditions();
+        credentialResolver = Constraint.isNotNull(resolver, "Credential resolver cannot be null");
+    }
+
+    /**
+     * Set the strategy used to validate the incoming JWT type header value.
+     * 
+     * @param strategy What to set.
+     */
+    public void setTypeHeaderValidationStrategy(
+            @Nonnull final BiPredicate<ProfileRequestContext,String> strategy) {
+        checkSetterPreconditions();
+        typeHeaderValidationStrategy = Constraint.isNotNull(strategy,
+                "Type header validation strategy cannot be null");
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void doInitialize() throws ComponentInitializationException {
+        super.doInitialize();
+
+        if (dataSealer == null) {
+            throw new ComponentInitializationException("Data sealer cannot be null");
+        }
+        if (claimsValidator == null) {
+            throw new ComponentInitializationException("Claims validator cannot be null");
+        }
+        if (credentialResolver == null) {
+            throw new ComponentInitializationException("Credential resolver cannot be null");
+        }
+        if (typeHeaderValidationStrategy == null) {
+            throw new ComponentInitializationException("Type header validation strategy cannot be null");
+        }
+    }
+
+    /** {@inheritDoc} */
+    @Override @Nullable
+    public RefreshTokenClaimsSet apply(@Nullable final ProfileRequestContext profileRequestContext,
+            @Nullable final String token) {
+        if (profileRequestContext == null) {
+            log.error("Profile request context is null");
+            return null;
+        }
+
+        final SignedJWT signedJWT;
+        final JWTClaimsSet claimsSet;
+        try {
+            signedJWT = SignedJWT.parse(token);
+            claimsSet = signedJWT.getJWTClaimsSet();
+        } catch (final ParseException e) {
+            log.debug("Could not parse the JWT out of incoming token {}", token, e);
+            return null;
+        }
+
+        if (claimsSet == null) {
+            log.warn("Could not find any claims inside the incoming JWT token");
+            return null;
+        }
+
+        final JOSEObjectType typ = signedJWT.getHeader().getType();
+        if (!typeHeaderValidationStrategy.test(profileRequestContext, typ == null ? null : typ.getType())) {
+            log.debug("JWT type header {} did not pass validation", typ != null ? typ.getType() : "null");
+            return null;
+        }
+
+        final Collection<Credential> credList = new ArrayList<>();
+        final CriteriaSet criteriaSet = new CriteriaSet(new UsageCriterion(UsageType.SIGNING));
+        try {
+            final Iterable<Credential> creds = credentialResolver.resolve(criteriaSet);
+            creds.forEach(credList::add);
+        } catch (final ResolverException e) {
+            log.error("Failure resolving signing credentials, can't verify JWT signature", e);
+            return null;
+        }
+        final String errorEventId = JWTSignatureValidationUtil.validateSignatureEx(credList, signedJWT,
+                OidcEventIds.INVALID_GRANT);
+        if (errorEventId != null) {
+            log.warn("Signature on refresh token ID '{}' invalid", claimsSet.getJWTID());
+            return null;
+        }
+
+        try {
+            claimsValidator.validate(claimsSet, profileRequestContext);
+        } catch (final JWTValidationException e) {
+            log.warn("JWT refresh token did not pass the claims validation", e);
+            return null;
+        }
+        
+        return decryptSealedClaimsSet(claimsSet);
+    }
+
+    /**
+     * Decrypt the sealed claims from 'for_op' claim and build {@link RefreshTokenClaimsSet} out of its contents.
+     * 
+     * @param claimsSet The claims set containing the 'for_op' claim.
+     * @return The refresh token claims set, or null if it couldn't be parsed from the claim
+     */
+    protected RefreshTokenClaimsSet decryptSealedClaimsSet(@Nonnull final JWTClaimsSet claimsSet) {
+        try {
+            final String sealedClaimsSet = claimsSet.getStringClaim(TokenClaimsSet.KEY_SEALED_FOR_OP);
+            if (sealedClaimsSet == null) {
+                log.error("Could not find sealed claims set from the JWT claims set");
+                return null;
+            }
+            assert dataSealer != null;
+            return RefreshTokenClaimsSet.parse(sealedClaimsSet, dataSealer);
+        } catch (final ParseException | DataSealerException e) {
+            log.warn("Could not decrypt the sealed claims set", e);
+        }
+        return null;
+    }
+}
diff --git a/idp-oidc-extension-impl/src/main/java/net/shibboleth/idp/plugin/oidc/op/profile/logic/DefaultJwtRefreshTokenSerializationFunction.java b/idp-oidc-extension-impl/src/main/java/net/shibboleth/idp/plugin/oidc/op/profile/logic/DefaultJwtRefreshTokenSerializationFunction.java
new file mode 100644
index 00000000..b90b8039
--- /dev/null
+++ b/idp-oidc-extension-impl/src/main/java/net/shibboleth/idp/plugin/oidc/op/profile/logic/DefaultJwtRefreshTokenSerializationFunction.java
@@ -0,0 +1,251 @@
+/*
+ * Licensed 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 net.shibboleth.idp.plugin.oidc.op.profile.logic;
+
+import java.util.Date;
+import java.util.function.BiFunction;
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.opensaml.messaging.context.MessageContext;
+import org.opensaml.messaging.context.navigate.ChildContextLookup;
+import org.opensaml.messaging.handler.MessageHandler;
+import org.opensaml.messaging.handler.MessageHandlerException;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.opensaml.profile.context.navigate.InboundMessageContextLookup;
+import org.slf4j.Logger;
+
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.SignedJWT;
+import com.nimbusds.oauth2.sdk.id.ClientID;
+
+import net.shibboleth.idp.plugin.oidc.op.token.support.RefreshTokenClaimsSet;
+import net.shibboleth.idp.plugin.oidc.op.token.support.TokenClaimsSet;
+import net.shibboleth.oidc.security.impl.JWSTokenSigner;
+import net.shibboleth.oidc.security.jose.SignatureException;
+import net.shibboleth.oidc.security.jose.SignatureSigningParameters;
+import net.shibboleth.oidc.security.jose.context.SecurityParametersContext;
+import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
+import net.shibboleth.shared.component.AbstractInitializableComponent;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.primitive.LoggerFactory;
+import net.shibboleth.shared.security.DataSealer;
+import net.shibboleth.shared.security.DataSealerException;
+
+/**
+ * Default implementation for serializing the refresh token claims set into a JWT refresh token.
+ * 
+ * The JWT refresh token contains the following claims:
+ * <ul>
+ * <li>iss - The issuer value fetched from {@link RefreshTokenClaimsSet#getIssuer()}</li>
+ * <li>aud - The audience value fetched from {@link #audienceLookupStrategy}</li>
+ * <li>iat - The issued at value fetched from {@link RefreshTokenClaimsSet#getIssuedAt()}</li>
+ * <li>exp - The expiration value fetched from {@link RefreshTokenClaimsSet#getExp()}</li>
+ * <li>jti - The token identifier value fetched from {@link RefreshTokenClaimsSet#getID()}</li>
+ * <li>client_id - The client ID value fetched from {@link RefreshTokenClaimsSet#getClientID()}</li>
+ * <li>for_op - the internal state information for OP's use (the sealed refresh token claims set)</li>
+ * </ul>
+ */
+public class DefaultJwtRefreshTokenSerializationFunction extends AbstractInitializableComponent
+     implements BiFunction<ProfileRequestContext,RefreshTokenClaimsSet,String> {
+
+    /** Class logger. */
+    @Nonnull private final Logger log = LoggerFactory.getLogger(DefaultJwtRefreshTokenSerializationFunction.class);
+
+    /** Data sealer for sealing private parts of the refresh token. */
+    @NonnullAfterInit private DataSealer dataSealer;
+
+    /** Strategy used to look up the {@link SecurityParametersContext} to set the parameters for. */
+    @Nonnull private Function<ProfileRequestContext, SecurityParametersContext> securityParametersContextLookupStrategy;
+
+    /** Handler that resolves and populates {@link SignatureSigningParameters}. */
+    @NonnullAfterInit MessageHandler signingParametersHandler;
+
+    /** Strategy used to lookup the type header value for the JWT. */
+    @NonnullAfterInit private BiFunction<ProfileRequestContext,RefreshTokenClaimsSet,String> typeHeaderLookupStrategy;
+
+    /** Strategy to find the audience value from the context.*/
+    @NonnullAfterInit private Function<ProfileRequestContext, String> audienceLookupStrategy;
+
+    /**
+     * Constructor.
+     */
+    public DefaultJwtRefreshTokenSerializationFunction() {
+        final Function<ProfileRequestContext, SecurityParametersContext> spcls =
+                new ChildContextLookup<>(SecurityParametersContext.class, true).compose(
+                        new InboundMessageContextLookup());
+        assert spcls != null;
+        securityParametersContextLookupStrategy = spcls;
+    }
+
+    /**
+     * Set the data sealer instance to use.
+     * 
+     * @param sealer What to set.
+     */
+    public void setDataSealer(@Nonnull final DataSealer sealer) {
+        checkSetterPreconditions();
+        dataSealer = Constraint.isNotNull(sealer, "Data sealer cannot be null");
+    }
+
+    /**
+     * Set the strategy used to look up the {@link SecurityParametersContext} to set the parameters for.
+     * 
+     * @param strategy What to set.
+     */
+    public void setSecurityParametersContextLookupStrategy(
+            @Nonnull final Function<ProfileRequestContext, SecurityParametersContext> strategy) {
+        checkSetterPreconditions();
+        securityParametersContextLookupStrategy = 
+                Constraint.isNotNull(strategy, "The security parameters context lookup strategy cannot be null");
+    }
+    /**
+     * Set the handler that resolves and populates {@link SignatureSigningParameters}.
+     * 
+     * @param handler What to set.
+     */
+    public void setSigningParametersHandler(@Nonnull final MessageHandler handler) {
+        checkSetterPreconditions();
+        signingParametersHandler = Constraint.isNotNull(handler, "The signing parameters handler cannot be null");
+    }
+
+    /**
+     * Set the strategy used to lookup the type header value for the JWT.
+     * 
+     * @param strategy What to set.
+     */
+    public void setTypeHeaderLookupStrategy(
+            @Nonnull final BiFunction<ProfileRequestContext,RefreshTokenClaimsSet,String> strategy) {
+        checkSetterPreconditions();
+        typeHeaderLookupStrategy = Constraint.isNotNull(strategy, "The type header lookup strategy cannot be null");
+    }
+
+    /**
+     * Set the audience lookup strategy.
+     * 
+     * @param strategy the strategy.
+     */
+    public void setAudienceLookupStrategy(
+            @Nonnull final Function<ProfileRequestContext, String> strategy) {
+        ifInitializedThrowUnmodifiabledComponentException();
+        
+        audienceLookupStrategy = Constraint.isNotNull(strategy, "Audience lookup strategy can not be null");
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void doInitialize() throws ComponentInitializationException {
+        super.doInitialize();
+
+        if (dataSealer == null) {
+            throw new ComponentInitializationException("The dat sealer cannot be null");            
+        }
+        if (signingParametersHandler == null) {
+            throw new ComponentInitializationException("The signing parameters handler cannot be null");
+        }
+        if (typeHeaderLookupStrategy == null) {
+            throw new ComponentInitializationException("The type header lookup strategy cannot be null");
+        }
+        if (audienceLookupStrategy == null) {
+            throw new ComponentInitializationException("The audience lookup strategy cannot be null)");
+        }
+    }
+
+    /** {@inheritDoc} */
+    @Override @Nullable
+    public String apply(@Nullable final ProfileRequestContext profileRequestContext,
+            @Nullable final RefreshTokenClaimsSet claimsSet) {
+        if (profileRequestContext == null || profileRequestContext.getInboundMessageContext() == null) {
+            log.error("Could not find inbound message context");
+            return null;
+        }
+        if (claimsSet == null) {
+            log.error("Given refresh token claims set was null");
+            return null;
+        }
+        final MessageContext messageContext = profileRequestContext.getInboundMessageContext();
+        assert messageContext != null;
+        try {
+            signingParametersHandler.invoke(messageContext);
+        } catch (final MessageHandlerException e) {
+            log.error("Could not populate signing parameters", e);
+            return null;
+        }
+        final SecurityParametersContext securityContext = securityParametersContextLookupStrategy.apply(profileRequestContext);
+        if (securityContext == null || securityContext.getSignatureSigningParameters() == null) {
+            log.error("Could not find signature signing parameters context after population");
+            return null;
+        }
+        final SignatureSigningParameters signingParameters = securityContext.getSignatureSigningParameters() ;
+        assert signingParameters != null;
+        final String typeHeader = typeHeaderLookupStrategy.apply(profileRequestContext, claimsSet);
+        final String audience = audienceLookupStrategy.apply(profileRequestContext);
+        if (audience == null) {
+            log.error("Could not resolve audience for the JWT");
+            return null;
+        }
+        final SignedJWT jwt = constructJWT(claimsSet, audience, signingParameters, typeHeader);
+        return jwt != null ? jwt.serialize() : null;
+    }
+
+    /**
+     * Construct a {@link SignedJWT} with the given input claims and signing parameters.
+     * @param claimsSet The input for payload
+     * @param signingParameters The signing parameters
+     * @return A signed JWT, or null
+     */
+    @Nullable protected SignedJWT constructJWT(@Nonnull final RefreshTokenClaimsSet claimsSet,
+            @Nonnull final String audience, @Nonnull final SignatureSigningParameters signingParameters,
+            @Nullable final String typeHeader) {
+        final String jti = claimsSet.getID();
+        if (jti == null) {
+            log.error("No ID was set in the claims set");
+            return null;
+        }
+        final ClientID clientId = claimsSet.getClientID();
+        if (clientId == null || clientId.getValue() == null) {
+            log.error("No clientID was set in the claims set");
+            return null;
+        }
+        final JWTClaimsSet jwtClaims;
+        try {
+            assert dataSealer != null;
+            final String sealedClaim = claimsSet.serialize(dataSealer);
+            jwtClaims = new JWTClaimsSet.Builder()
+                    .issuer(claimsSet.getIssuer())
+                    .audience(audience)
+                    .issueTime(Date.from(claimsSet.getIssuedAt()))
+                    .expirationTime(Date.from(claimsSet.getExp()))
+                    .jwtID(jti)
+                    .claim(TokenClaimsSet.KEY_CLIENTID, clientId.getValue())
+                    .claim(TokenClaimsSet.KEY_SEALED_FOR_OP, sealedClaim)
+                    .build();
+        } catch (final DataSealerException e) {
+            log.error("Could not encrypt the {} claim", TokenClaimsSet.KEY_SEALED_FOR_OP, e);
+            return null;
+        }
+        assert jwtClaims != null;
+        final JWSTokenSigner signer = new JWSTokenSigner(signingParameters);
+        try {
+            return signer.sign(jwtClaims, typeHeader);
+        } catch (final SignatureException e) {
+            log.error("Could not sign the JWT", e);
+        }
+        return null;
+    }
+}
diff --git a/idp-oidc-extension-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth2/introspection/introspection-beans.xml b/idp-oidc-extension-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth2/introspection/introspection-beans.xml
index d769dd4c..d1232104 100644
--- a/idp-oidc-extension-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth2/introspection/introspection-beans.xml
+++ b/idp-oidc-extension-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth2/introspection/introspection-beans.xml
@@ -39,7 +39,8 @@
     <bean id="ProcessTokenForIntrospection"
         class="net.shibboleth.idp.plugin.oidc.op.oauth2.profile.impl.ProcessTokenForIntrospection" scope="prototype"
         p:dataSealer-ref="#{'%{idp.oidc.tokenSealer:shibboleth.oidc.TokenSealer}'.trim()}"
-        p:credentialResolver-ref="RelyingPartyCredentialResolver"/>
+        p:credentialResolver-ref="RelyingPartyCredentialResolver"
+        p:refreshTokenDeserializers-ref="shibboleth.oidc.DefaultRefreshTokenDeserializers"/>
 
     <bean id="RelyingPartyCredentialResolver" class="net.shibboleth.profile.relyingparty.RelyingPartyCredentialResolver" 
         c:_0-ref="shibboleth.RelyingPartyResolverService" />
diff --git a/idp-oidc-extension-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth2/revocation/revocation-beans.xml b/idp-oidc-extension-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth2/revocation/revocation-beans.xml
index b0c0671c..993e7dcc 100644
--- a/idp-oidc-extension-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth2/revocation/revocation-beans.xml
+++ b/idp-oidc-extension-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth2/revocation/revocation-beans.xml
@@ -39,7 +39,8 @@
     <bean id="ProcessTokenForRevocation"
         class="net.shibboleth.idp.plugin.oidc.op.oauth2.profile.impl.ProcessTokenForRevocation" scope="prototype"
         p:dataSealer-ref="#{'%{idp.oidc.tokenSealer:shibboleth.oidc.TokenSealer}'.trim()}"
-        p:credentialResolver-ref="RelyingPartyCredentialResolver"/>
+        p:credentialResolver-ref="RelyingPartyCredentialResolver"
+        p:refreshTokenDeserializers-ref="shibboleth.oidc.DefaultRefreshTokenDeserializers"/>
 
     <bean id="RelyingPartyCredentialResolver" class="net.shibboleth.profile.relyingparty.RelyingPartyCredentialResolver" 
         c:_0-ref="shibboleth.RelyingPartyResolverService" />
diff --git a/idp-oidc-extension-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oidc/abstract-api/oidc-abstract-api-beans.xml b/idp-oidc-extension-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oidc/abstract-api/oidc-abstract-api-beans.xml
index 9239117f..7a271bf4 100644
--- a/idp-oidc-extension-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oidc/abstract-api/oidc-abstract-api-beans.xml
+++ b/idp-oidc-extension-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oidc/abstract-api/oidc-abstract-api-beans.xml
@@ -12,4 +12,53 @@
     <bean id="InitializeAuthenticationContext"
         class="net.shibboleth.idp.saml.profile.impl.InitializeAuthenticationContext" scope="prototype" />
 
+    <util:list id="shibboleth.oidc.DefaultRefreshTokenDeserializers">
+        <bean class="net.shibboleth.idp.plugin.oidc.op.profile.logic.DefaultJwtRefreshTokenDeserializationFunction"
+            scope="prototype"
+            p:dataSealer-ref="#{'%{idp.oidc.tokenSealer:shibboleth.oidc.TokenSealer}'.trim()}">
+            <property name="credentialResolver">
+                <bean class="net.shibboleth.profile.relyingparty.RelyingPartyCredentialResolver" 
+                    c:_0-ref="shibboleth.RelyingPartyResolverService" />
+            </property>
+            <property name="typeHeaderValidationStrategy">
+                <bean parent="shibboleth.BiConditions.Expression" c:expression="#input2 == null"/>
+            </property>
+            <property name="claimsValidator">
+                <bean class="net.shibboleth.oidc.security.jwt.claims.impl.ChainingJWTClaimsValidator">
+                    <property name="claimValidators">
+                        <util:list value-type="net.shibboleth.oidc.jwt.claims.ClaimsValidator">
+                            <bean class="net.shibboleth.oidc.security.jwt.claims.impl.RequiredClaimsValidator"
+                                p:requiredClaims="jti" />
+                            <bean class="net.shibboleth.oidc.security.jwt.claims.impl.ExpiryClaimsValidator"
+                                p:clockSkew="%{idp.policy.clockSkew:PT1M}" />
+                            <bean class="net.shibboleth.oidc.security.jwt.claims.impl.ExactMatchClaimsValidator"
+                                p:claimName="iss">
+                                <property name="valueToMatchLookupStrategy">
+                                    <bean class="net.shibboleth.shared.logic.BiFunctionSupport"
+                                        factory-method="forFunctionOfFirstArg"
+                                        c:_0-ref="shibboleth.ResponderIdLookup.Simple" />
+                                </property>
+                            </bean>
+                            <bean class="net.shibboleth.oidc.security.jwt.claims.impl.ExactMatchClaimsValidator"
+                                p:claimName="client_id">
+                                <property name="valueToMatchLookupStrategy">
+                                    <bean class="net.shibboleth.shared.logic.BiFunctionSupport"
+                                        factory-method="forFunctionOfFirstArg"
+                                        c:_0-ref="shibboleth.RelyingPartyIdLookup.Simple" />
+                                </property>
+                            </bean>
+                            <bean class="net.shibboleth.oidc.security.jwt.claims.impl.AudienceClaimsValidator">
+                                <property name="audienceLookupStrategy">
+                                    <bean parent="shibboleth.BiFunctions.Expression"
+                                        c:expression="#custom.get().getRequestURL().toString().replace('/profile/oauth2/introspection','/profile/oidc/token').replace('/profile/oauth2/revocation','/profile/oidc/token')"
+                                        p:customObject-ref="shibboleth.HttpServletRequestSupplier" />
+                                </property>
+                            </bean>
+                        </util:list>
+                    </property>
+                </bean>
+            </property>
+        </bean>
+    </util:list>
+
 </beans>
\ No newline at end of file
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 8ea453c1..056d204b 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
@@ -71,13 +71,14 @@
     <bean id="ValidateGrant" class="net.shibboleth.idp.plugin.oidc.op.profile.impl.ValidateGrant" scope="prototype"
         c:sealer-ref="#{'%{idp.oidc.tokenSealer:shibboleth.oidc.TokenSealer}'.trim()}"
         p:replayCache-ref="shibboleth.ReplayCache"
-        p:revocationCache-ref="shibboleth.oidc.RevocationCache">
+        p:revocationCache-ref="shibboleth.oidc.RevocationCache"
+        p:refreshTokenDeserializers-ref="#{'%{idp.oauth2.refreshToken.deserializers:shibboleth.oidc.DefaultRefreshTokenDeserializers}'.trim()}">
         <property name="chainRevocationLifetimeLookupStrategy">
             <bean class="net.shibboleth.idp.plugin.oidc.op.profile.logic.DefaultChainRevocationLifetimeLookupStrategy"
                 p:clockSkew="%{idp.policy.clockSkew:PT5M}" p:useActiveProfileOnly="false" />
         </property>
     </bean>
-    
+
     <bean id="ValidatePKCE" class="net.shibboleth.idp.plugin.oidc.op.profile.impl.ValidatePKCE" scope="prototype" />
 
     <bean id="ValidateRedirectURI" class="net.shibboleth.idp.plugin.oidc.op.oauth2.profile.impl.ValidateRedirectURI"
@@ -404,14 +405,63 @@
             class="net.shibboleth.idp.plugin.oidc.op.profile.impl.SetRefreshTokenToResponseContext" scope="prototype"
             c:sealer-ref="#{'%{idp.oidc.tokenSealer:shibboleth.oidc.TokenSealer}'.trim()}"
             p:revocationCache-ref="shibboleth.oidc.RevocationCache"
-            p:activationCondition-ref="#{'%{idp.oauth2.refreshToken.activation:DefaultRefreshTokenActivationCondition}'.trim()}">
+            p:activationCondition-ref="#{'%{idp.oauth2.refreshToken.activation:DefaultRefreshTokenActivationCondition}'.trim()}"
+            p:refreshTokenSerializationStrategies-ref="#{'%{idp.oauth2.refreshToken.serializationStrategies:shibboleth.oidc.DefaultRefreshTokenSerializationStrategies}'.trim()}">
             
         <property name="tokenRevocationLifetimeLookupStrategy">
             <bean class="net.shibboleth.idp.plugin.oidc.op.profile.logic.DefaultTokenRevocationLifetimeLookupStrategy"
                 p:clockSkew="%{idp.policy.clockSkew:PT5M}" />
         </property>
+        <property name="identifierGeneratorLookupStrategy">
+            <bean class="net.shibboleth.profile.config.navigate.IdentifierGenerationStrategyLookupFunction"
+                p:defaultIdentifierGenerationStrategy-ref="shibboleth.DefaultIdentifierGenerationStrategy" />
+        </property>
     </bean>
 
+    <util:map id="shibboleth.oidc.DefaultRefreshTokenSerializationStrategies" scope="prototype">
+        <entry key="JWT">
+            <bean class="net.shibboleth.idp.plugin.oidc.op.profile.logic.DefaultJwtRefreshTokenSerializationFunction"
+                scope="prototype"
+                p:dataSealer-ref="#{'%{idp.oidc.tokenSealer:shibboleth.oidc.TokenSealer}'.trim()}">
+                <property name="signingParametersHandler">
+                    <bean class="net.shibboleth.oidc.profile.impl.PopulateJWTSignatureSigningParametersHandler"
+                        scope="prototype">
+                        <property name="configurationLookupStrategy">
+                            <bean parent="shibboleth.Functions.Compose">
+                                <constructor-arg name="f">
+                                    <bean class="org.opensaml.messaging.context.navigate.ParentContextLookup"
+                                        c:type="org.opensaml.profile.context.ProfileRequestContext" />
+                                </constructor-arg>
+                                <constructor-arg name="g">
+                                    <bean lazy-init="true"
+                                        class="net.shibboleth.oidc.profile.config.navigate.JWTSignatureSigningConfigurationLookupFunction" />
+                                </constructor-arg>
+                            </bean>
+                        </property>
+                        <property name="signatureSigningParametersResolver">
+                            <bean class="net.shibboleth.oidc.security.jose.impl.ClientInformationSignatureSigningParametersResolver">
+                                <constructor-arg name="signatureAlgorithmLookupStrategy">
+                                    <bean class="net.shibboleth.oidc.profile.config.navigate.ClientInformationStringValueLookupFunction"
+                                        c:keyName="id_token_signed_response_alg" />
+                                </constructor-arg>
+                                <constructor-arg name="defaultAlgorithmValue" value="RS256" />
+                            </bean>
+                        </property>
+                    </bean>
+                </property>
+                <property name="audienceLookupStrategy">
+                    <bean parent="shibboleth.Functions.Expression"
+                        c:expression="#custom.get().getRequestURL().toString()"
+                        p:customObject-ref="shibboleth.HttpServletRequestSupplier" />
+                </property>
+                <property name="typeHeaderLookupStrategy">
+                    <bean parent="shibboleth.BiFunctions.Expression" c:expression="#null" />
+                </property>
+            </bean>
+        </entry>
+    </util:map>
+
+
     <bean id="DefaultRefreshTokenActivationCondition" parent="shibboleth.Conditions.AND">
         <constructor-arg>
             <list>
diff --git a/idp-oidc-extension-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/AbstractOidcApiFlowTest.java b/idp-oidc-extension-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/AbstractOidcApiFlowTest.java
index ade67587..c0336e11 100644
--- a/idp-oidc-extension-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/AbstractOidcApiFlowTest.java
+++ b/idp-oidc-extension-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/AbstractOidcApiFlowTest.java
@@ -22,6 +22,7 @@ import java.security.interfaces.ECPrivateKey;
 import java.time.Instant;
 import java.util.Collection;
 import java.util.Date;
+import java.util.List;
 
 import com.nimbusds.jose.Algorithm;
 import com.nimbusds.jose.JOSEException;
@@ -31,6 +32,7 @@ import com.nimbusds.jose.JWSHeader;
 import com.nimbusds.jose.JWSSigner;
 import com.nimbusds.jose.crypto.ECDSASigner;
 import com.nimbusds.jose.crypto.RSASSASigner;
+import com.nimbusds.jwt.JWTClaimsSet;
 import com.nimbusds.jwt.PlainJWT;
 import com.nimbusds.jwt.SignedJWT;
 import com.nimbusds.oauth2.sdk.Scope;
@@ -41,6 +43,7 @@ import com.nimbusds.openid.connect.sdk.claims.ClaimsSet;
 
 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;
 import net.shibboleth.shared.component.ComponentInitializationException;
 import net.shibboleth.shared.security.DataSealerException;
 
@@ -89,6 +92,29 @@ public class AbstractOidcApiFlowTest extends AbstractOidcFlowTest {
         return new RefreshToken(builder.build().serialize(getDataSealer()));
     }
 
+    protected String buildJwtRefreshToken(final String clientId, final String id, final String rootId,
+            final Scope scope, final List<String> aud, final Instant exp, final String type, 
+            final String... consentedClaims)
+                    throws NoSuchAlgorithmException, URISyntaxException, DataSealerException,
+                    ComponentInitializationException, JOSEException {
+        final String sealedForOp =
+                buildRefreshToken(clientId, "mockSub", scope, null, id, rootId, Instant.now(), exp).getValue();
+        final JWTClaimsSet claimsSet = new JWTClaimsSet.Builder()
+                .issuer("https://op.example.org")
+                .audience(aud)
+                .expirationTime(Date.from(Instant.now().plusSeconds(600)))
+                .jwtID(idGenerator.generateIdentifier())
+                .claim(TokenClaimsSet.KEY_CLIENTID, clientId)
+                .claim(TokenClaimsSet.KEY_SEALED_FOR_OP, sealedForOp)
+                .build();
+        final RSASSASigner signer = new RSASSASigner(loadRSSigningCredential().getPrivateKey());
+        final SignedJWT jwt = new SignedJWT(
+                new JWSHeader.Builder(JWSAlgorithm.RS256).type(type == null ? null : new JOSEObjectType(type)).build(),
+                claimsSet);
+        jwt.sign(signer);
+        return jwt.serialize();
+    }
+
     protected BearerAccessToken buildLegacyToken(final String clientId, final String subject, final Scope scope,
             final ClaimsSet userInfoDeliverySet, final String... consentedClaims)
             throws URISyntaxException, NoSuchAlgorithmException, DataSealerException, ComponentInitializationException {
diff --git a/idp-oidc-extension-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/IntrospectionFlowTest.java b/idp-oidc-extension-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/IntrospectionFlowTest.java
index 76874fee..9ff6545e 100644
--- a/idp-oidc-extension-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/IntrospectionFlowTest.java
+++ b/idp-oidc-extension-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/IntrospectionFlowTest.java
@@ -230,6 +230,47 @@ public class IntrospectionFlowTest extends AbstractOidcClientAuthenticationFlowT
         Assert.assertNull(resp.getAudience());
     }
 
+    @Test
+    public void testSuccessWithJwtRefreshToken() throws IOException, NoSuchAlgorithmException, URISyntaxException,
+            DataSealerException, ComponentInitializationException, JOSEException {
+        final String rootId = idGenerator.generateIdentifier();
+        final String jti = idGenerator.generateIdentifier();
+        storeMetadata(storageService, clientId, clientSecret, scope);
+        setBasicAuth(clientId, clientSecret);
+        setHttpFormRequest("POST", Map.of(
+                "token",
+                buildJwtRefreshToken(clientId, jti, rootId, Scope.parse("openid"),
+                        List.of("http://localhost/idp/profile/oidc/token"), Instant.now().plusSeconds(600), null),
+                "token_type",
+                "refresh_token"));
+        final FlowExecutionResult result = flowExecutor.launchExecution(FLOW_ID, null, externalContext);
+        final TokenIntrospectionSuccessResponse resp =
+                parseSuccessResponse(result, TokenIntrospectionSuccessResponse.class);
+        Assert.assertTrue(resp.isActive());
+        Assert.assertEquals(resp.getClientID().getValue(), clientId);
+        Assert.assertEquals(resp.getScope(), Scope.parse("openid"));
+        Assert.assertNull(resp.getAudience());
+    }
+
+    @Test
+    public void testFailureWithJwtRefreshTokenWrongType() throws IOException, NoSuchAlgorithmException,
+            URISyntaxException, DataSealerException, ComponentInitializationException, JOSEException {
+        final String rootId = idGenerator.generateIdentifier();
+        final String jti = idGenerator.generateIdentifier();
+        storeMetadata(storageService, clientId, clientSecret, scope);
+        setBasicAuth(clientId, clientSecret);
+        setHttpFormRequest("POST", Map.of(
+                "token",
+                buildJwtRefreshToken(clientId, jti, rootId, Scope.parse("openid"),
+                        List.of("http://localhost/idp/profile/oidc/token"), Instant.now().plusSeconds(600), "rt+jwt"),
+                "token_type",
+                "refresh_token"));
+        final FlowExecutionResult result = flowExecutor.launchExecution(FLOW_ID, null, externalContext);
+        final TokenIntrospectionSuccessResponse resp =
+                parseSuccessResponse(result, TokenIntrospectionSuccessResponse.class);
+        Assert.assertFalse(resp.isActive());
+    }
+
     @Test
     public void testFailureWithChainExpiredRefreshToken() throws IOException, NoSuchAlgorithmException,
             URISyntaxException, DataSealerException, ComponentInitializationException {
diff --git a/idp-oidc-extension-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/RevocationFlowTest.java b/idp-oidc-extension-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/RevocationFlowTest.java
index 2f70d031..1a9c8115 100644
--- a/idp-oidc-extension-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/RevocationFlowTest.java
+++ b/idp-oidc-extension-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/RevocationFlowTest.java
@@ -183,6 +183,38 @@ public class RevocationFlowTest extends AbstractOidcClientAuthenticationFlowTest
         Assert.assertFalse(revocationCache.isRevoked(RevocationCacheContexts.AUTHORIZATION_CODE, rootId));
     }
 
+    @Test
+    public void testSuccessSingleJwtRefreshToken() throws IOException, NoSuchAlgorithmException, URISyntaxException,
+        DataSealerException, ComponentInitializationException, JOSEException {
+        final String id = idGenerator.generateIdentifier();
+        final String rootId = idGenerator.generateIdentifier();
+        setBasicAuth(clientIdSingle, clientSecret);
+        storeMetadata(storageService, clientIdSingle, clientSecret, scope);
+        setHttpFormRequest("POST", Collections.singletonMap("token", buildJwtRefreshToken(clientIdSingle, id, 
+                rootId, Scope.parse("openid"), List.of("http://localhost/idp/profile/oidc/token"),
+                Instant.now().plusSeconds(600), null)));
+        final FlowExecutionResult result = flowExecutor.launchExecution(FLOW_ID, null, externalContext);
+        parseSuccessResponse(result, OAuth2RevocationSuccessResponse.class);
+        Assert.assertTrue(revocationCache.isRevoked(RevocationCacheContexts.SINGLE_ACCESS_OR_REFRESH_TOKENS, id));
+        Assert.assertFalse(revocationCache.isRevoked(RevocationCacheContexts.AUTHORIZATION_CODE, rootId));
+    }
+
+    @Test
+    public void testFailedSingleJwtRefreshTokenWrongType() throws IOException, NoSuchAlgorithmException,
+        URISyntaxException, DataSealerException, ComponentInitializationException, JOSEException {
+        final String id = idGenerator.generateIdentifier();
+        final String rootId = idGenerator.generateIdentifier();
+        setBasicAuth(clientIdSingle, clientSecret);
+        storeMetadata(storageService, clientIdSingle, clientSecret, scope);
+        setHttpFormRequest("POST", Collections.singletonMap("token", buildJwtRefreshToken(clientIdSingle, id, 
+                rootId, Scope.parse("openid"), List.of("http://localhost/idp/profile/oidc/token"),
+                Instant.now().plusSeconds(600), "rt+jwt")));
+        final FlowExecutionResult result = flowExecutor.launchExecution(FLOW_ID, null, externalContext);
+        parseSuccessResponse(result, OAuth2RevocationSuccessResponse.class);
+        Assert.assertFalse(revocationCache.isRevoked(RevocationCacheContexts.SINGLE_ACCESS_OR_REFRESH_TOKENS, id));
+        Assert.assertFalse(revocationCache.isRevoked(RevocationCacheContexts.AUTHORIZATION_CODE, rootId));
+    }
+
     @Test
     public void testSuccessSingleChainExpiredRefreshToken_notRevoked() throws IOException, NoSuchAlgorithmException,
         URISyntaxException, DataSealerException, ComponentInitializationException {
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 b71cf236..224048e0 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
@@ -43,7 +43,6 @@ import com.nimbusds.jose.EncryptionMethod;
 import com.nimbusds.jose.JWEAlgorithm;
 import com.nimbusds.jose.JWSAlgorithm;
 import com.nimbusds.jwt.JWT;
-import com.nimbusds.jwt.JWTClaimsSet;
 import com.nimbusds.jwt.SignedJWT;
 import com.nimbusds.oauth2.sdk.AccessTokenResponse;
 import com.nimbusds.oauth2.sdk.AuthorizationCode;
@@ -93,6 +92,7 @@ public class TokenFlowTest extends AbstractOidcClientAuthenticationFlowTest {
     String clientIdRefreshTokenRotation = "mockClientIdRefreshTokenRotation";
     String clientIdJwtAccessToken = "mockClientIdJwtAccessToken";
     String clientIdNoIdTokenViaRefreshToken = "mockClientIdNoIdTokenViaRefreshToken";
+    String clientIdJwtRefreshToken = "mockClientIdNotMDDrivenRefreshTokenJwt";
     String codeVerifier = "9234567812345678123456781234567812345678123456781234567812345678";
 
     String resourceUri = "https://rp.example.org";
@@ -124,6 +124,7 @@ public class TokenFlowTest extends AbstractOidcClientAuthenticationFlowTest {
         removeMetadata(storageService, clientIdNoIdTokenViaRefreshToken);
         removeMetadata(storageService, resourceUri);
         removeMetadata(storageService, resourceNonUri);
+        removeMetadata(storageService, clientIdNoIdTokenViaRefreshToken);
     }
 
     @Test
@@ -447,6 +448,24 @@ public class TokenFlowTest extends AbstractOidcClientAuthenticationFlowTest {
         Assert.assertEquals(getSidFromRefreshToken(response.getTokens().getRefreshToken()), sid);
         Assert.assertEquals(getSidFromJWT(response.getOIDCTokens().getIDToken()), sid);
     }
+
+    @Test
+    public void testValidGrantWithSidRefreshTokenJWT() throws Exception {
+        final String sid = idGenerator.generateIdentifier();
+        final String clientId = clientIdJwtRefreshToken;
+        initializeGrantAndRequest(clientId, createRequestParameters(redirectUri, "authorization_code",
+                buildAuthorizationCodeWithSid(clientId, sid), clientId));
+        storeConsent(storageService, "jdoe", clientId, "mail");
+        final FlowExecutionResult result = flowExecutor.launchExecution(FLOW_ID, null, externalContext);
+        final OIDCTokenResponse response = parseSuccessResponse(result, OIDCTokenResponse.class);
+        Assert.assertNotNull(response.getTokens().getAccessToken());
+        Assert.assertNotNull(response.getTokens().getRefreshToken());
+        Assert.assertNotNull(response.getOIDCTokens().getIDToken());
+        Assert.assertNotNull(response.getOIDCTokens().getIDToken().getJWTClaimsSet().getClaim("at_hash"));
+        Assert.assertEquals(getSidFromAccessToken(response.getTokens().getAccessToken()), sid);
+        Assert.assertEquals(getSidFromRefreshTokenJWT(response.getTokens().getRefreshToken()), sid);
+    }
+
     @Test
     public void testValidGrantNonMatchingRedirectURI() throws Exception {
         initializeGrantAndRequest(clientId, createRequestParameters(redirectUri + "wrong", "authorization_code",
@@ -756,22 +775,22 @@ public class TokenFlowTest extends AbstractOidcClientAuthenticationFlowTest {
 
     protected TokenClaimsSet buildDefaultClaimsSet(final String clientId, final Collection<String> aud,
             final Instant authTime) throws Exception {
-            final Instant now = Instant.now();
-            final AuthorizeCodeClaimsSet.Builder builder = new AuthorizeCodeClaimsSet.Builder();
-            builder.setJWTID(new SecureRandomIdentifierGenerationStrategy())
-                .setClientID(new ClientID(clientId))
-                .setIssuer(issuer)
-                .setPrincipal("jdoe")
-                .setSubject("mock")
-                .setIssuedAt(now)
-                .setExpiresAt(now.plusSeconds(100))
-                .setAuthenticationTime(authTime)
-                .setRedirectURI(new URI(redirectUri))
-                .setAudience(aud)
-                .setScope(scope == null ? new Scope() : scope);
-            return builder.build();
-        }
-
+        final Instant now = Instant.now();
+        final AuthorizeCodeClaimsSet.Builder builder = new AuthorizeCodeClaimsSet.Builder();
+        builder.setJWTID(new SecureRandomIdentifierGenerationStrategy())
+            .setClientID(new ClientID(clientId))
+            .setIssuer(issuer)
+            .setPrincipal("jdoe")
+            .setSubject("mock")
+            .setIssuedAt(now)
+            .setExpiresAt(now.plusSeconds(100))
+            .setAuthenticationTime(authTime)
+            .setRedirectURI(new URI(redirectUri))
+            .setAudience(aud)
+            .setScope(scope == null ? new Scope() : scope);
+        return builder.build();
+    }
+    
     protected String buildRefreshToken(final String clientId, final String id, final String rootId,
             final Collection<String> aud, final String... consentedClaims) throws Exception {
         final TokenClaimsSet acClaims = buildDefaultClaimsSet(clientId, aud);
@@ -1055,6 +1074,39 @@ public class TokenFlowTest extends AbstractOidcClientAuthenticationFlowTest {
         Assert.assertFalse(revocationCache.isRevoked(RevocationCacheContexts.AUTHORIZATION_CODE, rootId));
     }
 
+    @Test
+    public void testValidJwtRefreshTokenGrant() throws Exception {
+        final String id = idGenerator.generateIdentifier();
+        final String rootId = idGenerator.generateIdentifier();
+        initializeGrantAndRequest(clientId, createRequestParameters(redirectUri, "refresh_token",
+                buildJwtRefreshToken(clientId, id, rootId, scope, List.of("http://localhost/idp/profile/oidc/token"),
+                        Instant.now().plusSeconds(600), null),
+                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());
+        Assert.assertNotNull(response.getOIDCTokens().getIDToken().getJWTClaimsSet().getClaim("at_hash"));
+        Assert.assertNull(getSidFromAccessToken(accessToken));
+        Assert.assertNull(getSidFromJWT(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 testInvalidJwtRefreshTokenGrantUnsupportedType() throws Exception {
+        final String id = idGenerator.generateIdentifier();
+        final String rootId = idGenerator.generateIdentifier();
+        initializeGrantAndRequest(clientId, createRequestParameters(redirectUri, "refresh_token",
+                buildJwtRefreshToken(clientId, id, rootId, scope, List.of("http://localhost/idp/profile/oidc/token"),
+                        Instant.now().plusSeconds(600), "rt+jwt"),
+                clientId));
+        final FlowExecutionResult result = flowExecutor.launchExecution(FLOW_ID, null, externalContext);
+        assertErrorCode(result, OAuth2Error.INVALID_GRANT_CODE);
+    }
+
     @Test
     public void testChainExpiredRefreshTokenGrant() throws Exception {
         final String id = idGenerator.generateIdentifier();
@@ -1411,4 +1463,26 @@ public class TokenFlowTest extends AbstractOidcClientAuthenticationFlowTest {
         }
     }
 
+    protected String getSidFromRefreshTokenJWT(final RefreshToken refreshToken) {
+        assertRefreshTokenJWT(refreshToken);
+        final RefreshTokenClaimsSet claims;
+        try {
+            final SignedJWT signedJwt = SignedJWT.parse(refreshToken.getValue());
+            claims = RefreshTokenClaimsSet.parse(signedJwt.getJWTClaimsSet()
+                    .getStringClaim(TokenClaimsSet.KEY_SEALED_FOR_OP), getDataSealer());
+            return claims.getSessionIdentifier();
+        } catch (ParseException | DataSealerException e) {
+            return null;
+        }
+    }
+
+    protected void assertRefreshTokenJWT(final RefreshToken refreshToken) {
+        Assert.assertNotNull(refreshToken.getValue());
+        try {
+            Assert.assertNotNull(SignedJWT.parse(refreshToken.getValue()));
+        } catch (final ParseException e) {
+            Assert.fail();
+        }
+    }
+
 }
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 ae6bc171..fcb2dd60 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
@@ -39,6 +39,7 @@ import java.util.Map;
 import java.util.function.BiFunction;
 import java.util.function.Function;
 
+import org.mockito.Mockito;
 import org.opensaml.profile.action.EventIds;
 import org.opensaml.profile.context.ProfileRequestContext;
 import org.opensaml.storage.impl.MemoryStorageService;
@@ -47,6 +48,8 @@ import org.springframework.webflow.execution.Event;
 import org.testng.Assert;
 import org.testng.annotations.BeforeMethod;
 import org.testng.annotations.Test;
+
+import com.nimbusds.jwt.JWTClaimsSet;
 import com.nimbusds.oauth2.sdk.Scope;
 import com.nimbusds.oauth2.sdk.id.ClientID;
 import com.nimbusds.openid.connect.sdk.OIDCScopeValue;
@@ -60,6 +63,13 @@ public class SetRefreshTokenToResponseContextTest extends BaseOIDCResponseAction
     StorageServiceRevocationCache revocationCache;
 
     private boolean enforceRotation;
+    
+    @SuppressWarnings("unchecked")
+    private Function<ProfileRequestContext,String> refreshTokenTypeLookupStrategy = Mockito.mock(Function.class);
+
+    @SuppressWarnings("unchecked")
+    private Map<String, BiFunction<ProfileRequestContext,RefreshTokenClaimsSet,String>> serializers = 
+            Mockito.mock(Map.class);
 
     @BeforeMethod
     private void init() throws ComponentInitializationException, NoSuchAlgorithmException, URISyntaxException {
@@ -105,6 +115,8 @@ public class SetRefreshTokenToResponseContextTest extends BaseOIDCResponseAction
         action.setTokenRevocationLifetimeLookupStrategy(revocationLifetimeLookup);
         
         action.setEnforceRefreshTokenRotationCondition(prc -> enforceRotation);
+        action.setRefreshTokenTypeLookupStrategy(refreshTokenTypeLookupStrategy);
+        action.setRefreshTokenSerializationStrategies(serializers);
         action.initialize();
         return action;
     }
@@ -132,6 +144,39 @@ public class SetRefreshTokenToResponseContextTest extends BaseOIDCResponseAction
         Assert.assertEquals(rt.getRootTokenIdentifier(), jit);
     }
 
+    @Test
+    public void testSuccessViaCodeCustomSerializer() throws ComponentInitializationException, NoSuchAlgorithmException,
+            URISyntaxException, ParseException, DataSealerException {
+        final String jit = respCtx.getAuthorizationGrantClaimsSet().getID();
+        Mockito.when(refreshTokenTypeLookupStrategy.apply(Mockito.any())).thenReturn("customType");
+        Mockito.when(serializers.get(Mockito.eq("customType"))).thenReturn(mockSerializer());
+        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(), jit);
+        Assert.assertEquals(rt.getClaimsSet().getBooleanClaim("customFlag"), true);
+    }
+
+    protected BiFunction<ProfileRequestContext,RefreshTokenClaimsSet,String> mockSerializer() {
+        return (prc, claimsSet) -> {
+            try {
+                final Map<String,Object> jwtClaimsSet = claimsSet.getClaimsSet().toJSONObject();
+                jwtClaimsSet.put("customFlag", true);
+                claimsSet.setClaimsSet(JWTClaimsSet.parse(jwtClaimsSet));
+                return claimsSet.serialize(getDataSealer());
+            } catch (NoSuchAlgorithmException | DataSealerException | ComponentInitializationException
+                        | ParseException e) {
+                Assert.fail(e.getMessage());
+            }
+            return null;
+        };
+        
+    }
+
     @Test
     public void testSuccessViaCodeWithCustomClaim() throws ComponentInitializationException, NoSuchAlgorithmException, URISyntaxException,
             ParseException, DataSealerException {
@@ -181,6 +226,41 @@ public class SetRefreshTokenToResponseContextTest extends BaseOIDCResponseAction
         Assert.assertFalse(revocationCache.isRevoked(RevocationCacheContexts.SINGLE_ACCESS_OR_REFRESH_TOKENS, jit));
     }
 
+    @Test
+    public void testSuccessViaRefreshCustomSerializer() 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);
+        Mockito.when(refreshTokenTypeLookupStrategy.apply(Mockito.any())).thenReturn("customType");
+        Mockito.when(serializers.get(Mockito.eq("customType"))).thenReturn(mockSerializer());
+        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(rt.getChainExp().isAfter(Instant.now()));
+        Assert.assertFalse(revocationCache.isRevoked(RevocationCacheContexts.SINGLE_ACCESS_OR_REFRESH_TOKENS, jit));
+        Assert.assertEquals(rt.getClaimsSet().getBooleanClaim("customFlag"), true);
+    }
+
     @Test
     public void testSuccessViaRefresh_existingChainExp() throws ComponentInitializationException, NoSuchAlgorithmException, URISyntaxException,
             ParseException, DataSealerException {
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 587e895f..4539ebe5 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
@@ -25,13 +25,17 @@ import net.shibboleth.idp.profile.testing.ActionTestingSupport;
 import net.shibboleth.oidc.profile.core.OidcEventIds;
 import net.shibboleth.shared.component.ComponentInitializationException;
 import net.shibboleth.shared.logic.ConstraintViolationException;
+import net.shibboleth.shared.security.DataSealerException;
 import net.shibboleth.shared.security.impl.SecureRandomIdentifierGenerationStrategy;
 
 import java.net.URI;
 import java.security.NoSuchAlgorithmException;
+import java.text.ParseException;
 import java.time.Duration;
 import java.time.Instant;
 import java.util.Collection;
+import java.util.List;
+import java.util.function.BiFunction;
 import java.util.function.Function;
 
 import org.mockito.Mockito;
@@ -93,17 +97,19 @@ public class ValidateGrantTest extends BaseOIDCResponseActionTest {
     }
 
     private void init(final Instant authenticationTime) throws Exception {
-        init(true, new MockRevocationCache(false, true), null, authenticationTime);
+        init(true, new MockRevocationCache(false, true), null, authenticationTime, null);
     }
 
     private void init(boolean refreshTokensEnabled, final RevocationCache revocationCache,
             final Function<ProfileRequestContext, Duration> revocationLifetimeLookup) throws Exception {
-        init(refreshTokensEnabled, revocationCache, revocationLifetimeLookup, Instant.now());
+        init(refreshTokensEnabled, revocationCache, revocationLifetimeLookup, Instant.now(), null);
     }
 
     private void init(boolean refreshTokensEnabled, final RevocationCache revocationCache,
             final Function<ProfileRequestContext, Duration> revocationLifetimeLookup,
-            final Instant authenticationTime) throws Exception {
+            final Instant authenticationTime,
+            final List<BiFunction<ProfileRequestContext,String,RefreshTokenClaimsSet>> deserializers)
+                    throws Exception {
         final Instant now = Instant.now();
         rootTokenId = "mockId" + now.toEpochMilli();
         acClaims = new AuthorizeCodeClaimsSet.Builder()
@@ -135,6 +141,9 @@ public class ValidateGrantTest extends BaseOIDCResponseActionTest {
             action.setChainRevocationLifetimeLookupStrategy(revocationLifetimeLookup);
         }
         action.setRevocationCache(revocationCache);
+        if (deserializers != null) {
+            action.setRefreshTokenDeserializers(deserializers);
+        }
         final StorageServiceReplayCache replayCache = new StorageServiceReplayCache();
         replayCache.setStorage(storageService);
         action.setReplayCache(replayCache);
@@ -243,6 +252,42 @@ public class ValidateGrantTest extends BaseOIDCResponseActionTest {
         Assert.assertNotNull(arc.getAuthorizationGrantClaimsSet());
     }
 
+    @Test
+    public void testCustomRefreshTokenNoDeserializers() throws Exception {
+        init();
+        final RefreshToken customToken = new RefreshToken("customPrefix" + rfGrant.getRefreshToken().getValue());
+        final TokenRequest req = new TokenRequest(callback, new ClientID(clientId), new RefreshTokenGrant(customToken));
+        profileRequestCtx.getInboundMessageContext().setMessage(req);
+        ActionTestingSupport.assertEvent(action.execute(requestCtx), OidcEventIds.INVALID_GRANT);
+    }
+
+    @Test
+    public void testCustomRefreshTokenSuccess() throws Exception {
+        final List<BiFunction<ProfileRequestContext,String,RefreshTokenClaimsSet>> deserializers =
+                List.of(new BiFunction<>() {
+
+                    @Override
+                    public RefreshTokenClaimsSet apply(final ProfileRequestContext prc, final String value ) {
+                        try {
+                            return RefreshTokenClaimsSet.parse(value.substring("customPrefix".length()), getDataSealer());
+                        } catch (NoSuchAlgorithmException | ParseException | DataSealerException
+                                | ComponentInitializationException e) {
+                            Assert.fail("Could not decrypt the custom refresh token", e);
+                        }
+                        return null;
+                    }
+            
+        });
+        init(true, new MockRevocationCache(false, true), null, Instant.now(), deserializers);
+        final RefreshToken customToken = new RefreshToken("customPrefix" + rfGrant.getRefreshToken().getValue());
+        final TokenRequest req = new TokenRequest(callback, new ClientID(clientId), new RefreshTokenGrant(customToken));
+        profileRequestCtx.getInboundMessageContext().setMessage(req);
+        ActionTestingSupport.assertProceedEvent(action.execute(requestCtx));
+        final OIDCAuthenticationResponseContext arc =
+                profileRequestCtx.getOutboundMessageContext().getSubcontext(OIDCAuthenticationResponseContext.class);
+        Assert.assertNotNull(arc.getAuthorizationGrantClaimsSet());
+    }
+
     @Test
     public void testRefreshTokenChainExpired() throws Exception {
         init(Instant.now().minus(Duration.ofHours(2)));
diff --git a/idp-oidc-extension-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/logic/DefaultJwtRefreshTokenDeserializationFunctionTest.java b/idp-oidc-extension-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/logic/DefaultJwtRefreshTokenDeserializationFunctionTest.java
new file mode 100644
index 00000000..0fbd4c15
--- /dev/null
+++ b/idp-oidc-extension-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/logic/DefaultJwtRefreshTokenDeserializationFunctionTest.java
@@ -0,0 +1,205 @@
+/*
+ * Licensed 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 net.shibboleth.idp.plugin.oidc.op.profile.logic;
+
+import java.security.NoSuchAlgorithmException;
+import java.time.Instant;
+import java.util.List;
+import java.util.function.BiPredicate;
+
+import javax.annotation.Nonnull;
+
+import org.mockito.Mockito;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.opensaml.security.credential.Credential;
+import org.opensaml.security.credential.CredentialResolver;
+import org.testng.Assert;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JOSEObjectType;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.JWSSigner;
+import com.nimbusds.jose.crypto.MACSigner;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.PlainJWT;
+import com.nimbusds.jwt.SignedJWT;
+import com.nimbusds.oauth2.sdk.Scope;
+import com.nimbusds.oauth2.sdk.id.ClientID;
+
+import net.shibboleth.idp.plugin.oidc.op.profile.impl.BaseOIDCResponseActionTest;
+import net.shibboleth.idp.plugin.oidc.op.token.support.RefreshTokenClaimsSet;
+import net.shibboleth.idp.plugin.oidc.op.token.support.TokenClaimsSet;
+import net.shibboleth.oidc.jwt.claims.ClaimsValidator;
+import net.shibboleth.oidc.jwt.claims.JWTValidationException;
+import net.shibboleth.oidc.security.credential.DefaultClientSecretCredential;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.resolver.ResolverException;
+import net.shibboleth.shared.security.DataSealer;
+import net.shibboleth.shared.security.DataSealerException;
+
+/**
+ * Unit tests for {@link DefaultJwtRefreshTokenDeserializationFunction}.
+ */
+public class DefaultJwtRefreshTokenDeserializationFunctionTest {
+
+    @Nonnull final String clientSecret = "mockSecret1234567890mockSecret1234567890mockSecret1234567890";
+    DefaultJwtRefreshTokenDeserializationFunction function;
+    ClaimsValidator claimsValidator = Mockito.mock(ClaimsValidator.class);
+    CredentialResolver credentialResolver = Mockito.mock(CredentialResolver.class);
+    @SuppressWarnings("unchecked")
+    BiPredicate<ProfileRequestContext,String> typeHeaderValidationStrategy = Mockito.mock(BiPredicate.class);
+    DataSealer dataSealer;
+
+    @BeforeClass
+    protected void initSealer() {
+        try {
+            dataSealer = BaseOIDCResponseActionTest.initializeDataSealer();
+        } catch (final ComponentInitializationException | NoSuchAlgorithmException e) {
+            Assert.fail("Could not initialize the data sealer", e);
+        }
+    }
+
+    @BeforeMethod
+    protected void initFunction() {
+        try {
+            function = new DefaultJwtRefreshTokenDeserializationFunction();
+            assert dataSealer != null;
+            function.setDataSealer(dataSealer);
+            assert claimsValidator != null;
+            function.setClaimsValidator(claimsValidator);
+            function.setCredentialResolver(credentialResolver);
+            assert typeHeaderValidationStrategy != null;
+            function.setTypeHeaderValidationStrategy(typeHeaderValidationStrategy);
+            function.initialize();
+        } catch (final ComponentInitializationException e) {
+            Assert.fail("Could not initialize the function", e);
+        }
+    }
+
+    @Test
+    public void testNullPrc() {
+        Assert.assertNull(function.apply(null, "mockToken"));
+    }
+
+    @Test
+    public void testNonJwtToken() {
+        Assert.assertNull(function.apply(new ProfileRequestContext(), "mockToken"));
+    }
+
+    @Test
+    public void testPlainJwtToken() throws DataSealerException {
+        final PlainJWT jwt = new PlainJWT(mockJwtClaimsSet());
+        Assert.assertNull(function.apply(new ProfileRequestContext(), jwt.serialize()));
+    }
+
+    @Test
+    public void testSignedJwtNullTypeNotAllowed() throws ResolverException {
+        Mockito.when(typeHeaderValidationStrategy.test(Mockito.any(), Mockito.isNull())).thenReturn(false);
+        final SignedJWT jwt = mockSignedJwt(null);
+        Assert.assertNull(function.apply(new ProfileRequestContext(), jwt.serialize()));
+    }
+
+    @SuppressWarnings("null")
+    @Test
+    public void testSignedJwtNullTypeAllowed() throws ResolverException, JWTValidationException {
+        Mockito.when(typeHeaderValidationStrategy.test(Mockito.any(), Mockito.isNull())).thenReturn(true);
+        Mockito.when(credentialResolver.resolve(Mockito.any())).thenReturn(List.of(clientSecretCredential()));
+        Mockito.doNothing().when(claimsValidator).validate(Mockito.any(), Mockito.any());
+        final SignedJWT jwt = mockSignedJwt(null);
+        Assert.assertNotNull(function.apply(new ProfileRequestContext(), jwt.serialize()));
+    }
+
+    @Test
+    public void testSignedJwtTypeNotAllowed() throws ResolverException {
+        Mockito.when(typeHeaderValidationStrategy.test(Mockito.any(), Mockito.eq("rt+jwt"))).thenReturn(false);
+        final SignedJWT jwt = mockSignedJwt("rt+jwt");
+        Assert.assertNull(function.apply(new ProfileRequestContext(), jwt.serialize()));
+    }
+
+    @SuppressWarnings("null")
+    @Test
+    public void testSignedJwtTypeAllowed() throws ResolverException, JWTValidationException {
+        Mockito.when(typeHeaderValidationStrategy.test(Mockito.any(), Mockito.eq("rt+jwt"))).thenReturn(true);
+        Mockito.when(credentialResolver.resolve(Mockito.any())).thenReturn(List.of(clientSecretCredential()));
+        Mockito.doNothing().when(claimsValidator).validate(Mockito.any(), Mockito.any());
+        final SignedJWT jwt = mockSignedJwt("rt+jwt");
+        Assert.assertNotNull(function.apply(new ProfileRequestContext(), jwt.serialize()));
+    }
+
+    @Test
+    public void testSignedJwtInvalidSignature() throws ResolverException {
+        Mockito.when(typeHeaderValidationStrategy.test(Mockito.any(), Mockito.eq("rt+jwt"))).thenReturn(true);
+        Mockito.when(credentialResolver.resolve(Mockito.any())).thenReturn(List.of(wrongSecretCredential()));
+        final SignedJWT jwt = mockSignedJwt("rt+jwt");
+        Assert.assertNull(function.apply(new ProfileRequestContext(), jwt.serialize()));
+    }
+
+    @SuppressWarnings("null")
+    @Test
+    public void testSignedJwtClaimsValidationFails() throws ResolverException, JWTValidationException {
+        Mockito.when(typeHeaderValidationStrategy.test(Mockito.any(), Mockito.eq("rt+jwt"))).thenReturn(true);
+        Mockito.when(credentialResolver.resolve(Mockito.any())).thenReturn(List.of(clientSecretCredential()));
+        Mockito.doThrow(JWTValidationException.class).when(claimsValidator).validate(Mockito.any(), Mockito.any());
+        final SignedJWT jwt = mockSignedJwt("rt+jwt");
+        Assert.assertNull(function.apply(new ProfileRequestContext(), jwt.serialize()));
+    }
+
+    @SuppressWarnings("null")
+    protected JWTClaimsSet mockJwtClaimsSet() throws DataSealerException {
+        return new JWTClaimsSet.Builder()
+                .claim(TokenClaimsSet.KEY_SEALED_FOR_OP, new RefreshTokenClaimsSet.Builder()
+                        .setJWTID("mockId")
+                        .setClientID(new ClientID("mockClientId"))
+                        .setIssuedAt(Instant.now())
+                        .setExpiresAt(Instant.now().plusSeconds(600))
+                        .setIssuer("mockIssuer")
+                        .setAuthenticationTime(Instant.now())
+                        .setSubject("mockSubject")
+                        .setScope(Scope.parse(""))
+                        .build()
+                        .serialize(dataSealer))
+                .build();
+    }
+
+    protected SignedJWT mockSignedJwt(final String type) {
+        try {
+            final JWSSigner signer = 
+                    new MACSigner(clientSecret.getBytes());
+            final SignedJWT signedJwt = new SignedJWT(
+                    new JWSHeader.Builder(JWSAlgorithm.HS256)
+                        .type(type == null ? null : new JOSEObjectType(type))
+                        .build(),
+                    mockJwtClaimsSet());
+            signedJwt.sign(signer);
+            return signedJwt;
+        } catch (final JOSEException | DataSealerException e) {
+            Assert.fail("Could not build a signed JWT", e);
+        }
+        return null;
+    }
+
+    protected Credential clientSecretCredential() {
+        return new DefaultClientSecretCredential(clientSecret).toSigningCredential();
+    }
+
+    protected Credential wrongSecretCredential() {
+        return new DefaultClientSecretCredential(clientSecret + "2").toSigningCredential();
+    }
+
+}
diff --git a/idp-oidc-extension-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/logic/DefaultJwtRefreshTokenSerializationFunctionTest.java b/idp-oidc-extension-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/logic/DefaultJwtRefreshTokenSerializationFunctionTest.java
new file mode 100644
index 00000000..26d77a5b
--- /dev/null
+++ b/idp-oidc-extension-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/logic/DefaultJwtRefreshTokenSerializationFunctionTest.java
@@ -0,0 +1,205 @@
+/*
+ * Licensed 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 net.shibboleth.idp.plugin.oidc.op.profile.logic;
+
+import java.security.NoSuchAlgorithmException;
+import java.text.ParseException;
+import java.time.Instant;
+import java.util.List;
+import java.util.function.BiFunction;
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+
+import org.mockito.Mockito;
+import org.opensaml.messaging.context.MessageContext;
+import org.opensaml.messaging.handler.MessageHandler;
+import org.opensaml.messaging.handler.MessageHandlerException;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.opensaml.security.credential.Credential;
+import org.testng.Assert;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+import com.nimbusds.jwt.SignedJWT;
+import com.nimbusds.oauth2.sdk.Scope;
+import com.nimbusds.oauth2.sdk.id.ClientID;
+
+import net.shibboleth.idp.plugin.oidc.op.profile.impl.BaseOIDCResponseActionTest;
+import net.shibboleth.idp.plugin.oidc.op.token.support.RefreshTokenClaimsSet;
+import net.shibboleth.oidc.profile.core.OidcEventIds;
+import net.shibboleth.oidc.security.credential.DefaultClientSecretCredential;
+import net.shibboleth.oidc.security.impl.JWTSignatureValidationUtil;
+import net.shibboleth.oidc.security.jose.SignatureSigningParameters;
+import net.shibboleth.oidc.security.jose.context.SecurityParametersContext;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.security.DataSealer;
+
+/**
+ * Unit tests for {@link DefaultJwtRefreshTokenSerializationFunction}.
+ */
+public class DefaultJwtRefreshTokenSerializationFunctionTest {
+
+    @Nonnull final String clientSecret = "mockSecret1234567890mockSecret1234567890mockSecret1234567890";
+    DefaultJwtRefreshTokenSerializationFunction function;
+    @SuppressWarnings("unchecked")
+    Function<ProfileRequestContext, SecurityParametersContext> securityParametersContextLookupStrategy = 
+            Mockito.mock(Function.class);
+    MessageHandler signingParametersHandler = Mockito.mock(MessageHandler.class);
+    @SuppressWarnings("unchecked")
+    BiFunction<ProfileRequestContext,RefreshTokenClaimsSet,String> typeHeaderLookupStrategy = 
+            Mockito.mock(BiFunction.class);
+    @SuppressWarnings("unchecked")
+    Function<ProfileRequestContext, String> audienceLookupStrategy = Mockito.mock(Function.class);
+    DataSealer dataSealer;
+
+    @BeforeClass
+    protected void initSealer() {
+        try {
+            dataSealer = BaseOIDCResponseActionTest.initializeDataSealer();
+        } catch (final ComponentInitializationException | NoSuchAlgorithmException e) {
+            Assert.fail("Could not initialize the data sealer", e);
+        }
+    }
+
+    @BeforeMethod
+    protected void initFunction() {
+        try {
+            function = new DefaultJwtRefreshTokenSerializationFunction();
+            assert dataSealer != null;
+            function.setDataSealer(dataSealer);
+            assert securityParametersContextLookupStrategy != null;
+            function.setSecurityParametersContextLookupStrategy(securityParametersContextLookupStrategy);
+            assert signingParametersHandler != null;
+            function.setSigningParametersHandler(signingParametersHandler);
+            assert typeHeaderLookupStrategy != null;
+            function.setTypeHeaderLookupStrategy(typeHeaderLookupStrategy);
+            assert audienceLookupStrategy != null;
+            function.setAudienceLookupStrategy(audienceLookupStrategy);
+            function.initialize();
+        } catch (final ComponentInitializationException e) {
+            Assert.fail("Could not initialize the function", e);
+        }
+    }
+
+    @Test
+    public void testNullPrc() {
+        Assert.assertNull(function.apply(null, mockRefreshTokenClaimsSet()));
+    }
+
+    @Test
+    public void testNullInboundMessageContext() {
+        Assert.assertNull(function.apply(new ProfileRequestContext(), mockRefreshTokenClaimsSet()));
+    }
+
+    @Test
+    public void testNullClaimsSet() {
+        Assert.assertNull(function.apply(mockProfileRequestContext(), null));
+    }
+
+    @SuppressWarnings("null")
+    @Test
+    public void testSigningParametersHandlerException() throws MessageHandlerException {
+        Mockito.doThrow(MessageHandlerException.class).when(signingParametersHandler).invoke(Mockito.notNull());
+        Assert.assertNull(function.apply(mockProfileRequestContext(), mockRefreshTokenClaimsSet()));
+    }
+
+    @SuppressWarnings("null")
+    @Test
+    public void testSecurityParametersContextLookupStrategyReturningNull() throws MessageHandlerException {
+        Mockito.doNothing().when(signingParametersHandler).invoke(Mockito.any());
+        Mockito.when(securityParametersContextLookupStrategy.apply(Mockito.any())).thenReturn(null);
+        Assert.assertNull(function.apply(mockProfileRequestContext(), mockRefreshTokenClaimsSet()));
+    }
+
+    @SuppressWarnings("null")
+    @Test
+    public void testSecurityParametersContextLookupStrategyReturningNoParams() throws MessageHandlerException {
+        final SecurityParametersContext securityParametersContext = new SecurityParametersContext();
+        securityParametersContext.setSignatureSigningParameters(new SignatureSigningParameters());
+        Mockito.doNothing().when(signingParametersHandler).invoke(Mockito.any());
+        Mockito.when(securityParametersContextLookupStrategy.apply(Mockito.any()))
+            .thenReturn(new SecurityParametersContext());
+        Assert.assertNull(function.apply(mockProfileRequestContext(), mockRefreshTokenClaimsSet()));
+    }
+
+    @SuppressWarnings("null")
+    @Test
+    public void testNoAudience() throws MessageHandlerException {
+        final SecurityParametersContext securityParametersContext = new SecurityParametersContext();
+        securityParametersContext.setSignatureSigningParameters(new SignatureSigningParameters());
+        Mockito.doNothing().when(signingParametersHandler).invoke(Mockito.any());
+        Mockito.when(securityParametersContextLookupStrategy.apply(Mockito.any()))
+            .thenReturn(securityParametersContext);
+        Mockito.when(audienceLookupStrategy.apply(Mockito.any())).thenReturn(null);
+        Assert.assertNull(function.apply(mockProfileRequestContext(), mockRefreshTokenClaimsSet()));
+    }
+
+    @SuppressWarnings("null")
+    @Test
+    public void testSignerFailing() throws MessageHandlerException {
+        final SecurityParametersContext securityParametersContext = new SecurityParametersContext();
+        securityParametersContext.setSignatureSigningParameters(new SignatureSigningParameters());
+        Mockito.doNothing().when(signingParametersHandler).invoke(Mockito.any());
+        Mockito.when(securityParametersContextLookupStrategy.apply(Mockito.any()))
+            .thenReturn(securityParametersContext);
+        Mockito.when(audienceLookupStrategy.apply(Mockito.any())).thenReturn("mockAudience");
+        Assert.assertNull(function.apply(mockProfileRequestContext(), mockRefreshTokenClaimsSet()));
+    }
+
+    @SuppressWarnings("null")
+    @Test
+    public void testSuccess() throws MessageHandlerException, ParseException {
+        final SecurityParametersContext securityParametersContext = new SecurityParametersContext();
+        final SignatureSigningParameters signingParameters = new SignatureSigningParameters();
+        signingParameters.setSigningCredential(clientSecretCredential());
+        signingParameters.setSignatureAlgorithm("HS256");
+        securityParametersContext.setSignatureSigningParameters(signingParameters);
+        Mockito.doNothing().when(signingParametersHandler).invoke(Mockito.any());
+        Mockito.when(securityParametersContextLookupStrategy.apply(Mockito.any()))
+            .thenReturn(securityParametersContext);
+        Mockito.when(audienceLookupStrategy.apply(Mockito.any())).thenReturn("mockAudience");
+        final String result = function.apply(mockProfileRequestContext(), mockRefreshTokenClaimsSet());
+        final SignedJWT jwt = SignedJWT.parse(result);
+        Assert.assertNull(JWTSignatureValidationUtil.validateSignatureEx(List.of(clientSecretCredential()), jwt,
+                OidcEventIds.INVALID_GRANT));
+    }
+
+    protected ProfileRequestContext mockProfileRequestContext() {
+        final ProfileRequestContext profileRequestContext = new ProfileRequestContext();
+        profileRequestContext.setInboundMessageContext(new MessageContext());
+        return profileRequestContext;
+    }
+
+    @SuppressWarnings("null")
+    protected RefreshTokenClaimsSet mockRefreshTokenClaimsSet() {
+        return new RefreshTokenClaimsSet.Builder()
+                .setJWTID("mockId")
+                .setClientID(new ClientID("mockClientId"))
+                .setIssuedAt(Instant.now())
+                .setExpiresAt(Instant.now().plusSeconds(600))
+                .setIssuer("mockIssuer")
+                .setAuthenticationTime(Instant.now())
+                .setSubject("mockSubject")
+                .setScope(Scope.parse(""))
+                .build();
+    }
+
+    protected Credential clientSecretCredential() {
+        return new DefaultClientSecretCredential(clientSecret).toSigningCredential();
+    }
+
+}
diff --git a/idp-oidc-extension-impl/src/test/resources/net/shibboleth/idp/module/conf/relying-party.xml b/idp-oidc-extension-impl/src/test/resources/net/shibboleth/idp/module/conf/relying-party.xml
index bdddb0ea..89255cec 100644
--- a/idp-oidc-extension-impl/src/test/resources/net/shibboleth/idp/module/conf/relying-party.xml
+++ b/idp-oidc-extension-impl/src/test/resources/net/shibboleth/idp/module/conf/relying-party.xml
@@ -85,6 +85,17 @@
                  </list>
             </property>
         </bean>
+        <bean parent="RelyingPartyByName" c:relyingPartyIds="mockClientIdNotMDDrivenRefreshTokenJwt">
+            <property name="profileConfigurations">
+                 <list>
+                     <ref bean="OIDC.SSO" />
+                     <bean parent="OAUTH2.Token" p:refreshTokenType="JWT"/>
+                     <ref bean="OIDC.UserInfo" />
+                     <ref bean="OAUTH2.Introspection" />
+                     <ref bean="OAUTH2.Revocation" />
+                 </list>
+            </property>
+        </bean>
         <bean parent="RelyingPartyByName" c:relyingPartyIds="mockClientIdEncryptionEnforced">
             <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