[java-idp-oidc] branch main updated: JOIDC-235 - AdministrativeLogoutConfiguration for OIDC
Henri Mikkonen
henri.mikkonen at iki.fi
Wed Jun 11 14:57:28 UTC 2025
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=bd4c97928ae6ea8455ed77f8f079baaf6685ba7b
The following commit(s) were added to refs/heads/main by this push:
new bd4c9792 JOIDC-235 - AdministrativeLogoutConfiguration for OIDC
bd4c9792 is described below
commit bd4c97928ae6ea8455ed77f8f079baaf6685ba7b
Author: Henri Mikkonen <henri.mikkonen at iki.fi>
AuthorDate: Wed Jun 11 17:57:00 2025 +0300
JOIDC-235 - AdministrativeLogoutConfiguration for OIDC
https://shibboleth.atlassian.net/browse/JOIDC-235
Initial implementation for the administrative token revocation -hook and attribute based revocation implementation
- The hook is of type: BiPredicate<ProfileRequestContext,JWTClaimsSet>
- May be activated via 'idp.oauth2.revocationCondition', defaults to false
- May be customised via 'idp.oauth2.revocationCondition.custom', defaults to 'shibboleth.oauth2.revocationCondition.AttributeTokenRevocationCondition'
- The attribute ID for the default bean can be customized via 'idp.oauth2.revocationCondition.attributeId', defaults to 'revocation'
- Activated bean is wired to:
- ValidateGrant SWF-action in the token flow (to cover authorization code and refresh token grant vaidation)
- Default claims validator sets for introspection and userinfo profiles
---
.../plugin/oidc/op/profile/impl/ValidateGrant.java | 23 ++
.../logic/AttributeTokenRevocationCondition.java | 277 +++++++++++++++++++++
.../impl/TokenRevocationConditionValidator.java | 57 +++++
.../META-INF/net.shibboleth.idp/postconfig.xml | 10 +
.../idp/flows/oidc/token/token-beans.xml | 3 +-
.../idp/service/relying-party/postconfig.xml | 7 +
.../idp/plugin/oidc/op/conf/oidc.properties | 7 +
.../op/profile/flow/AbstractOidcApiFlowTest.java | 2 +
.../op/profile/flow/IntrospectionFlowTest.java | 78 +++++-
.../plugin/oidc/op/profile/flow/TokenFlowTest.java | 22 ++
.../plugin/oidc/op/profile/flow/UserInfoTest.java | 25 ++
.../AttributeTokenRevocationConditionTest.java | 167 +++++++++++++
.../idp/module/conf/attribute-resolver.xml | 11 +
.../net/shibboleth/idp/module/conf/global.xml | 10 +
.../net/shibboleth/idp/module/conf/oidc.properties | 5 +-
15 files changed, 701 insertions(+), 3 deletions(-)
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 70f41ea0..c884911d 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
@@ -19,6 +19,7 @@ import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.function.BiFunction;
+import java.util.function.BiPredicate;
import java.util.function.Function;
import java.util.function.Predicate;
@@ -32,6 +33,7 @@ import org.opensaml.storage.ReplayCache;
import org.opensaml.storage.RevocationCache;
import org.slf4j.Logger;
+import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.oauth2.sdk.AuthorizationCodeGrant;
import com.nimbusds.oauth2.sdk.AuthorizationGrant;
import com.nimbusds.oauth2.sdk.GrantType;
@@ -111,6 +113,9 @@ public class ValidateGrant extends AbstractOIDCTokenResponseAction {
/** Strategy used to locate thumbprint of validated DPoP Proof JWT. */
@Nonnull private Function<ProfileRequestContext, String> dpopProofThumbprintLookupStrategy;
+ /** Predicate used to indicate whether the authorization code or refresh token is revoked. */
+ @Nullable private BiPredicate<ProfileRequestContext,JWTClaimsSet> tokenRevocationCondition;
+
/** The RelyingPartyContext to operate on. */
@Nullable private RelyingPartyContext rpCtx;
@@ -226,6 +231,18 @@ public class ValidateGrant extends AbstractOIDCTokenResponseAction {
dpopProofThumbprintLookupStrategy = Constraint.isNotNull(strategy, "Lookup strategy cannot be null");
}
+ /**
+ * Set the predicate used to indicate whether the authorization code or refresh token is revoked.
+ *
+ * @param condition token revocation condition
+ *
+ * @since 4.3.0
+ */
+ public void setTokenRevocationCondition(@Nullable final BiPredicate<ProfileRequestContext,JWTClaimsSet> condition) {
+ checkSetterPreconditions();
+ tokenRevocationCondition = condition;
+ }
+
/** {@inheritDoc} */
@Override
protected void doInitialize() throws ComponentInitializationException {
@@ -402,6 +419,12 @@ public class ValidateGrant extends AbstractOIDCTokenResponseAction {
ActionSupport.buildEvent(profileRequestContext, OidcEventIds.INVALID_GRANT);
return;
}
+ if (tokenRevocationCondition != null
+ && tokenRevocationCondition.test(profileRequestContext, tokenClaimsSet.getClaimsSet())) {
+ log.warn("{} Token has been revoked by the token revocation condition", getLogPrefix());
+ ActionSupport.buildEvent(profileRequestContext, OidcEventIds.INVALID_GRANT);
+ return;
+ }
final ClientID clientId = tokenClaimsSet.getClientID();
assert clientId != null;
assert rpCtx != null;
diff --git a/idp-oidc-extension-impl/src/main/java/net/shibboleth/idp/plugin/oidc/op/profile/logic/AttributeTokenRevocationCondition.java b/idp-oidc-extension-impl/src/main/java/net/shibboleth/idp/plugin/oidc/op/profile/logic/AttributeTokenRevocationCondition.java
new file mode 100644
index 00000000..b0986eea
--- /dev/null
+++ b/idp-oidc-extension-impl/src/main/java/net/shibboleth/idp/plugin/oidc/op/profile/logic/AttributeTokenRevocationCondition.java
@@ -0,0 +1,277 @@
+/*
+ * 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.time.DateTimeException;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Date;
+import java.util.function.BiFunction;
+import java.util.function.BiPredicate;
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.opensaml.messaging.context.ScratchContext;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+
+import com.nimbusds.jwt.JWTClaimsSet;
+
+import net.shibboleth.idp.attribute.DateTimeAttributeValue;
+import net.shibboleth.idp.attribute.IdPAttribute;
+import net.shibboleth.idp.attribute.IdPAttributeValue;
+import net.shibboleth.idp.attribute.StringAttributeValue;
+import net.shibboleth.idp.attribute.resolver.AttributeResolver;
+import net.shibboleth.idp.attribute.resolver.context.AttributeResolutionContext;
+import net.shibboleth.idp.plugin.oidc.op.token.support.TokenClaimsSet;
+import net.shibboleth.profile.context.navigate.IssuerLookupFunction;
+import net.shibboleth.profile.context.navigate.RelyingPartyIdLookupFunction;
+import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
+import net.shibboleth.shared.annotation.constraint.NotEmpty;
+import net.shibboleth.shared.collection.CollectionSupport;
+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.primitive.StringSupport;
+import net.shibboleth.shared.service.ReloadableService;
+
+/**
+ * A condition that checks for token revocation against a resolved {@link IdPAttribute}.
+ *
+ * @since 4.3.0
+ */
+public class AttributeTokenRevocationCondition extends AbstractInitializableComponent
+ implements BiPredicate<ProfileRequestContext,JWTClaimsSet> {
+
+ /** Class logger. */
+ @Nonnull private final Logger log = LoggerFactory.getLogger(AttributeTokenRevocationCondition.class);
+
+ /** Lookup strategy for principal name. */
+ @NonnullAfterInit private BiFunction<ProfileRequestContext,JWTClaimsSet,String> principalNameLookupStrategy;
+
+ /** Strategy used to locate the identity of the issuer associated with the attribute resolution. */
+ @Nullable private Function<ProfileRequestContext,String> issuerLookupStrategy;
+
+ /** Strategy used to locate the identity of the recipient associated with the attribute resolution. */
+ @Nullable private Function<ProfileRequestContext,String> recipientLookupStrategy;
+
+ /** Attribute Resolver service. */
+ @NonnullAfterInit private ReloadableService<AttributeResolver> attributeResolver;
+
+ /** Attribute ID to resolve. */
+ @NonnullAfterInit @NotEmpty private String attributeId;
+
+ /** Constructor. */
+ public AttributeTokenRevocationCondition() {
+ issuerLookupStrategy = new IssuerLookupFunction();
+ recipientLookupStrategy = new RelyingPartyIdLookupFunction();
+ }
+
+ /**
+ * Set lookup strategy for principal name.
+ *
+ * @param strategy lookup strategy
+ */
+ public void setPrincipalNameLookupStrategy(
+ @Nonnull final BiFunction<ProfileRequestContext,JWTClaimsSet,String> strategy) {
+ checkSetterPreconditions();
+
+ principalNameLookupStrategy = Constraint.isNotNull(strategy, "Principal name lookup strategy cannot be null");
+ }
+
+ /**
+ * Set the strategy used to lookup the issuer for this attribute resolution.
+ *
+ * @param strategy lookup strategy
+ */
+ public void setIssuerLookupStrategy(@Nullable final Function<ProfileRequestContext,String> strategy) {
+ checkSetterPreconditions();
+
+ issuerLookupStrategy = strategy;
+ }
+
+ /**
+ * Set the strategy used to lookup the recipient for this attribute resolution.
+ *
+ * @param strategy lookup strategy
+ */
+ public void setRecipientLookupStrategy(@Nullable final Function<ProfileRequestContext,String> strategy) {
+ checkSetterPreconditions();
+
+ recipientLookupStrategy = strategy;
+ }
+
+ /**
+ * Set {@link AttributeResolver} to use.
+ *
+ * @param service attribute resolver service
+ */
+ public void setAttributeResolver(@Nonnull final ReloadableService<AttributeResolver> service) {
+ checkSetterPreconditions();
+
+ attributeResolver = Constraint.isNotNull(service, "ReloadableService<AttributeResolver> cannot be null");
+ }
+
+ /**
+ * Set the ID of an {@link IdPAttribute} to resolve to obtain revocation records for the principal.
+ *
+ * @param id attribute ID to resolve
+ */
+ public void setAttributeId(@Nonnull @NotEmpty final String id) {
+ checkSetterPreconditions();
+
+ attributeId = Constraint.isNotNull(StringSupport.trimOrNull(id), "Attribute ID cannot be null or empty");
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ protected void doInitialize() throws ComponentInitializationException {
+ super.doInitialize();
+
+ if (attributeResolver == null) {
+ throw new ComponentInitializationException("ReloadableService<AttributeResolver> cannot be null");
+ } else if (principalNameLookupStrategy == null) {
+ throw new ComponentInitializationException("Principal name lookup strategy cannot be null");
+ } else if (attributeId == null) {
+ throw new ComponentInitializationException("Attribute ID to resolve cannot be null or empty");
+ }
+ }
+
+ /** {@inheritDoc} */
+ @SuppressWarnings("unchecked")
+ public boolean test(@Nullable final ProfileRequestContext profileRequestContext,
+ @Nullable final JWTClaimsSet claimsSet) {
+ checkComponentActive();
+
+ if (profileRequestContext == null || claimsSet == null) {
+ log.error("Called with null inputs");
+ return true;
+ }
+
+ final String principal = principalNameLookupStrategy.apply(profileRequestContext, claimsSet);
+ if (principal == null) {
+ log.error("Principal lookup strategy returned null value");
+ return true;
+ }
+
+ log.debug("Checking revocation for principal name {} for token {} result via attribute resolver", principal,
+ claimsSet.getJWTID());
+
+ final ScratchContext context = profileRequestContext.ensureSubcontext(ScratchContext.class);
+
+ if (!context.getMap().containsKey(getClass())) {
+ final AttributeResolutionContext resolutionContext = buildResolutionContext(profileRequestContext,
+ principal);
+ assert attributeResolver != null;
+ resolutionContext.resolveAttributes(attributeResolver);
+
+ final Collection<Instant> records = new ArrayList<>();
+ if (resolutionContext.getResolvedIdPAttributes().containsKey(attributeId)) {
+ for (final IdPAttributeValue value :
+ resolutionContext.getResolvedIdPAttributes().get(attributeId).getValues()) {
+ if (value instanceof DateTimeAttributeValue) {
+ records.add(((DateTimeAttributeValue) value).getValue());
+ } else if (value instanceof StringAttributeValue) {
+ try {
+ records.add(Instant.ofEpochSecond(Long.valueOf(((StringAttributeValue) value).getValue())));
+ } catch (final NumberFormatException|DateTimeException e) {
+ log.error("Error parsing timestamp '{}' into epoch",
+ ((StringAttributeValue) value).getValue(), e);
+ }
+ } else {
+ log.warn("Ignoring non-string attribute value type: {}", value.getClass().getName());
+ }
+ }
+ } else {
+ log.debug("Resolver did not return an IdPAttribute named {} for principal {}", attributeId, principal);
+ }
+
+ context.getMap().put(getClass(), records);
+ resolutionContext.removeFromParent();
+ }
+
+ return isRevoked(principal, claimsSet, (Collection<Instant>) context.getMap().get(getClass()));
+ }
+
+ /**
+ * Build an {@link AttributeResolutionContext} to use.
+ *
+ * @param profileRequestContext profile request context
+ * @param principal name of principal
+ *
+ * @return the attached context
+ */
+ @Nonnull private AttributeResolutionContext buildResolutionContext(
+ @Nonnull final ProfileRequestContext profileRequestContext, @Nonnull @NotEmpty final String principal) {
+
+ final AttributeResolutionContext resolutionContext = new AttributeResolutionContext();
+
+ resolutionContext
+ .setPrincipal(principal)
+ .setResolutionLabel("Token revocation");
+ assert attributeId != null;
+ resolutionContext.setRequestedIdPAttributeNames(CollectionSupport.singletonList(attributeId));
+
+ if (recipientLookupStrategy != null) {
+ resolutionContext.setAttributeRecipientID(recipientLookupStrategy.apply(profileRequestContext));
+ }
+
+ if (issuerLookupStrategy != null) {
+ resolutionContext.setAttributeIssuerID(issuerLookupStrategy.apply(profileRequestContext));
+ }
+
+ profileRequestContext.addSubcontext(resolutionContext, true);
+ return resolutionContext;
+ }
+
+ /**
+ * Check the revocation records' timestamps for applicability.
+ *
+ * @param principal name of principal
+ * @param claimsSet claims set containing {@link TokenClaimsSet#KEY_AUTH_TIME}
+ * @param revocationRecords the records from the cache
+ *
+ * @return true iff the revocation applies to this claims set
+ */
+ protected boolean isRevoked(@Nonnull @NotEmpty final String principal, @Nonnull final JWTClaimsSet claimsSet,
+ @Nonnull final Collection<Instant> revocationRecords) {
+
+ for (final Instant i : revocationRecords) {
+ final Date authTime;
+ try {
+ authTime = claimsSet.getDateClaim(TokenClaimsSet.KEY_AUTH_TIME);
+ } catch (final ParseException e) {
+ log.debug("Could not parse authentication time from the claims set for {}", principal, e);
+ log.info("Token identifier {} for principal {} is revoked: authentication time cannot be parsed",
+ principal, claimsSet.getJWTID());
+ return true;
+ }
+ log.debug("Authentication time in token {} vs revocation record {}", authTime.toInstant(), i);
+ if (authTime.toInstant().isBefore(i)) {
+ log.info("Token identifier {} for principal {} has been revoked", claimsSet.getJWTID(),
+ principal);
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+}
\ No newline at end of file
diff --git a/idp-oidc-extension-impl/src/main/java/net/shibboleth/idp/plugin/oidc/op/security/jwt/claims/impl/TokenRevocationConditionValidator.java b/idp-oidc-extension-impl/src/main/java/net/shibboleth/idp/plugin/oidc/op/security/jwt/claims/impl/TokenRevocationConditionValidator.java
new file mode 100644
index 00000000..a610eb89
--- /dev/null
+++ b/idp-oidc-extension-impl/src/main/java/net/shibboleth/idp/plugin/oidc/op/security/jwt/claims/impl/TokenRevocationConditionValidator.java
@@ -0,0 +1,57 @@
+/*
+ * 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.security.jwt.claims.impl;
+
+import java.util.function.BiPredicate;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.opensaml.profile.context.ProfileRequestContext;
+
+import com.nimbusds.jwt.JWTClaimsSet;
+
+import net.shibboleth.oidc.jwt.claims.AbstractClaimsValidator;
+import net.shibboleth.oidc.jwt.claims.JWTValidationException;
+
+/**
+ * Verifies the claims set against configurable token revocation condition.
+ */
+public class TokenRevocationConditionValidator extends AbstractClaimsValidator {
+
+ /** Token revocation condition to use. */
+ @Nullable private BiPredicate<ProfileRequestContext,JWTClaimsSet> tokenRevocationCondition;
+
+ /**
+ * Set the token revocation condition to use.
+ *
+ * @param condition token revocation condition to set
+ */
+ public void setTokenRevocationCondition(@Nullable final BiPredicate<ProfileRequestContext,JWTClaimsSet> condition) {
+ checkSetterPreconditions();
+ tokenRevocationCondition = condition;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ protected void doValidate(@Nonnull final JWTClaimsSet claims,
+ @Nullable final ProfileRequestContext profileRequestContext) throws JWTValidationException {
+
+ if (tokenRevocationCondition != null && tokenRevocationCondition.test(profileRequestContext, claims)) {
+ throw new JWTValidationException(
+ "Token " + claims.getJWTID() + " for subject '" + claims.getSubject() + "' has been revoked");
+ }
+ }
+}
\ No newline at end of file
diff --git a/idp-oidc-extension-impl/src/main/resources/META-INF/net.shibboleth.idp/postconfig.xml b/idp-oidc-extension-impl/src/main/resources/META-INF/net.shibboleth.idp/postconfig.xml
index 532f73f7..af2e2b82 100644
--- a/idp-oidc-extension-impl/src/main/resources/META-INF/net.shibboleth.idp/postconfig.xml
+++ b/idp-oidc-extension-impl/src/main/resources/META-INF/net.shibboleth.idp/postconfig.xml
@@ -883,4 +883,14 @@
p:customMetadataPolicyOperators="#{getObject('shibboleth.oidc.RedirectUriValidator.MetadataPolicyCustomOperators') ?: getObject('shibboleth.oidc.DefaultMetadataPolicyCustomOperators')}"/>
</property>
</bean>
+
+ <bean id="shibboleth.oauth2.revocationCondition.AttributeTokenRevocationCondition"
+ class="net.shibboleth.idp.plugin.oidc.op.profile.logic.AttributeTokenRevocationCondition" lazy-init="true"
+ p:attributeResolver-ref="shibboleth.AttributeResolverService"
+ p:attributeId="#{'%{idp.oauth2.revocationCondition.attributeId:revocation}'.trim()}">
+ <property name="principalNameLookupStrategy">
+ <bean parent="shibboleth.BiFunctions.Expression" c:expression="#input2.getSubject()" />
+ </property>
+ </bean>
+
</beans>
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 8507a891..49d8b38f 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
@@ -77,7 +77,8 @@
c:sealer-ref="#{'%{idp.oidc.tokenSealer:shibboleth.oidc.TokenSealer}'.trim()}"
p:replayCache-ref="shibboleth.ReplayCache"
p:revocationCache-ref="shibboleth.oidc.RevocationCache"
- p:refreshTokenDeserializers-ref="#{'%{idp.oauth2.refreshToken.deserializers:shibboleth.oidc.DefaultRefreshTokenDeserializers}'.trim()}">
+ p:refreshTokenDeserializers-ref="#{'%{idp.oauth2.refreshToken.deserializers:shibboleth.oidc.DefaultRefreshTokenDeserializers}'.trim()}"
+ p:tokenRevocationCondition="#{%{idp.oauth2.revocationCondition:false} == true ? getObject('%{idp.oauth2.revocationCondition.custom:shibboleth.oauth2.revocationCondition.AttributeTokenRevocationCondition}') : {null} }">
<property name="chainRevocationLifetimeLookupStrategy">
<bean class="net.shibboleth.idp.plugin.oidc.op.profile.logic.DefaultChainRevocationLifetimeLookupStrategy"
p:clockSkew="%{idp.policy.clockSkew:PT5M}" p:useActiveProfileOnly="false" />
diff --git a/idp-oidc-extension-impl/src/main/resources/META-INF/net/shibboleth/idp/service/relying-party/postconfig.xml b/idp-oidc-extension-impl/src/main/resources/META-INF/net/shibboleth/idp/service/relying-party/postconfig.xml
index 94ff61cf..9f98a984 100644
--- a/idp-oidc-extension-impl/src/main/resources/META-INF/net/shibboleth/idp/service/relying-party/postconfig.xml
+++ b/idp-oidc-extension-impl/src/main/resources/META-INF/net/shibboleth/idp/service/relying-party/postconfig.xml
@@ -601,6 +601,11 @@
p:revocationCache-ref="shibboleth.oidc.RevocationCache"
p:context="#{T(net.shibboleth.idp.plugin.oidc.op.storage.RevocationCacheContexts).AUTHORIZATION_CODE}" />
+ <bean id="TokenRevocationConditionValidator"
+ class="net.shibboleth.idp.plugin.oidc.op.security.jwt.claims.impl.TokenRevocationConditionValidator"
+ p:tokenRevocationCondition="#{%{idp.oauth2.revocationCondition:false} == true ? getObject('%{idp.oauth2.revocationCondition.custom:shibboleth.oauth2.revocationCondition.AttributeTokenRevocationCondition}') : {null} }">
+ </bean>
+
<util:list id="IntrospectionClaimsValidators" value-type="net.shibboleth.oidc.jwt.claims.ClaimsValidator">
<ref bean="RequiredClaimsValidator" />
<ref bean="ExpiryClaimsValidator" />
@@ -619,6 +624,7 @@
</bean>
<ref bean="JWTIDRevocationClaimsValidator" />
<ref bean="RootJWTIDRevocationClaimsValidator" />
+ <ref bean="TokenRevocationConditionValidator" />
</util:list>
<util:list id="RevocationClaimsValidators" value-type="net.shibboleth.oidc.jwt.claims.ClaimsValidator">
@@ -664,6 +670,7 @@
<ref bean="OPInAudienceClaimsValidator" />
<ref bean="JWTIDRevocationClaimsValidator" />
<ref bean="RootJWTIDRevocationClaimsValidator" />
+ <ref bean="TokenRevocationConditionValidator" />
</util:list>
<!--
diff --git a/idp-oidc-extension-impl/src/main/resources/net/shibboleth/idp/plugin/oidc/op/conf/oidc.properties b/idp-oidc-extension-impl/src/main/resources/net/shibboleth/idp/plugin/oidc/op/conf/oidc.properties
index 7d602743..6462997f 100644
--- a/idp-oidc-extension-impl/src/main/resources/net/shibboleth/idp/plugin/oidc/op/conf/oidc.properties
+++ b/idp-oidc-extension-impl/src/main/resources/net/shibboleth/idp/plugin/oidc/op/conf/oidc.properties
@@ -198,6 +198,13 @@ idp.oidc.subject.salt = this_too_should_be_ch4ng3d
# Revocation method: set to TOKEN to revoke single tokens (defaults to full chain (value = CHAIN))
#idp.oauth2.revocationMethod = TOKEN
+# Revocation condition: activate (disabled by default) and modify the attribute resolution parameters
+#idp.oauth2.revocationCondition = false
+# Custom bean name that implements BiPredicate<ProfileRequestContext,JWTClaimsSet> for activating condition
+#idp.oauth2.revocationCondition.custom = shibboleth.oauth2.revocationCondition.AttributeTokenRevocationCondition
+# The attribute ID for the shibboleth.oauth2.revocationCondition.AttributeTokenRevocationCondition
+#idp.oauth2.revocationCondition.attributeId = revocation
+
# Bean used to validate audience claim in the JWT authentication.
#idp.oauth2.jwtAuth.audienceValidator = DefaultAuthenticationAudienceClaimsValidator
# The default pattern also accepts token endpoint URL as the audience in introspection, revocation and PAR endpoints.
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 b938522d..cd507a0e 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
@@ -55,6 +55,8 @@ import net.shibboleth.shared.security.DataSealerException;
* Abstract unit test for the OIDC flows using access tokens.
*/
public class AbstractOidcApiFlowTest extends AbstractOidcFlowTest {
+
+ String clientIdActivateRevocationCondition = "mockClientIdActivateRevocationCondition";
protected AbstractOidcApiFlowTest(final String flowId) {
super(flowId);
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 98107a7a..5e1cdca4 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
@@ -92,6 +92,7 @@ public class IntrospectionFlowTest extends AbstractOidcClientAuthenticationFlowT
removeMetadata(storageService, clientIdDPoPProofEnforced);
removeMetadata(storageService, clientIdEndpointAudienceDisabled);
removeMetadata(storageService, clientIdRequireClientAuthenticationJWTType);
+ removeMetadata(storageService, clientIdActivateRevocationCondition);
}
@Test
@@ -719,7 +720,82 @@ public class IntrospectionFlowTest extends AbstractOidcClientAuthenticationFlowT
Assert.assertNull(resp.getClientID());
Assert.assertFalse(resp.isActive());
}
-
+
+ @Test
+ public void testFailureWithRevokedAccessToken() throws IOException, NoSuchAlgorithmException, URISyntaxException,
+ DataSealerException, ComponentInitializationException {
+ final String clientId = clientIdActivateRevocationCondition;
+ storeMetadata(storageService, clientId, clientSecret, scope);
+ setBasicAuth(clientId, clientSecret);
+ setHttpFormRequest("POST", Map.of(
+ "token",
+ buildToken(clientId, "sub", Scope.parse("openid")).toJSONObject().getAsString("access_token"),
+ "token_type",
+ "access_token"));
+ final FlowExecutionResult result = flowExecutor.launchExecution(FLOW_ID, null, externalContext);
+ final TokenIntrospectionSuccessResponse resp =
+ parseSuccessResponse(result, TokenIntrospectionSuccessResponse.class);
+ Assert.assertFalse(resp.isActive());
+ }
+
+ @Test
+ public void testFailureWithRevokedJwtAccessToken() throws IOException, NoSuchAlgorithmException, URISyntaxException,
+ DataSealerException, ComponentInitializationException, JOSEException {
+ final String clientId = clientIdActivateRevocationCondition;
+ storeMetadata(storageService, clientId, clientSecret, scope);
+ setBasicAuth(clientId, clientSecret);
+ setHttpFormRequest("POST", Map.of(
+ "token",
+ buildJWTToken(clientId, "sub", scope, null, signingKey.getPrivateKey(), "RS256").toJSONObject()
+ .getAsString("access_token"),
+ "token_type",
+ "access_token"));
+ final FlowExecutionResult result = flowExecutor.launchExecution(FLOW_ID, null, externalContext);
+ final TokenIntrospectionSuccessResponse resp =
+ parseSuccessResponse(result, TokenIntrospectionSuccessResponse.class);
+ Assert.assertFalse(resp.isActive());
+ }
+
+ @Test
+ public void testFailureWithRevokedRefreshToken() throws IOException, NoSuchAlgorithmException, URISyntaxException,
+ DataSealerException, ComponentInitializationException {
+ final String clientId = clientIdActivateRevocationCondition;
+ final String rootId = idGenerator.generateIdentifier();
+ final String jti = idGenerator.generateIdentifier();
+ storeMetadata(storageService, clientId, clientSecret, scope);
+ setBasicAuth(clientId, clientSecret);
+ setHttpFormRequest("POST", Map.of(
+ "token",
+ buildRefreshToken(clientId, "sub", Scope.parse("openid"), null, jti, rootId).toJSONObject()
+ .getAsString("refresh_token"),
+ "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 testFailureWithRevokedJwtRefreshToken() throws IOException, NoSuchAlgorithmException, URISyntaxException,
+ DataSealerException, ComponentInitializationException, JOSEException {
+ final String clientId = clientIdActivateRevocationCondition;
+ 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.assertFalse(resp.isActive());
+ }
+
protected FlowExecutionResult launchWithJwtAuthentication(final JWT jwt, final JWSAlgorithm algorithm,
final ClientAuthenticationMethod method, final PublicKey publicKey) throws Exception {
// use 'iss' claim from JWT as clientId if set, 'sub' otherwise
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 18ae8094..71334a81 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
@@ -141,6 +141,7 @@ public class TokenFlowTest extends AbstractOidcClientAuthenticationFlowTest {
removeMetadata(storageService, clientIdAlwaysBearerAccessToken);
removeMetadata(storageService, clientIdEndpointAudienceDisabled);
removeMetadata(storageService, clientIdRequireClientAuthenticationJWTType);
+ removeMetadata(storageService, clientIdActivateRevocationCondition);
}
@Test
@@ -1370,6 +1371,27 @@ public class TokenFlowTest extends AbstractOidcClientAuthenticationFlowTest {
Assert.assertNotNull(response.getOIDCTokens().getIDToken());
}
+ @Test
+ public void testRevokedAuthorizationGrant() throws Exception {
+ final String clientId = clientIdActivateRevocationCondition;
+ initializeGrantAndRequest(clientId, createRequestParameters(redirectUri, "authorization_code",
+ buildAuthorizationCode(clientId), clientId));
+ storeConsent(storageService, "jdoe", clientId, "mail");
+ final FlowExecutionResult result = flowExecutor.launchExecution(FLOW_ID, null, externalContext);
+ assertErrorCode(result, OAuth2Error.INVALID_GRANT_CODE);
+ }
+
+ @Test
+ public void testRevokedRefreshTokenGrant() throws Exception {
+ final String clientId = clientIdActivateRevocationCondition;
+ final String id = idGenerator.generateIdentifier();
+ final String rootId = idGenerator.generateIdentifier();
+ initializeGrantAndRequest(clientId, createRequestParameters(redirectUri, "refresh_token",
+ buildRefreshToken(clientId, id, rootId, null), clientId));
+ final FlowExecutionResult result = flowExecutor.launchExecution(FLOW_ID, null, externalContext);
+ assertErrorCode(result, OAuth2Error.INVALID_GRANT_CODE);
+ }
+
protected String buildAuthorizationCode(final String clientId) throws Exception {
return buildAuthorizationCode(clientId, null);
}
diff --git a/idp-oidc-extension-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/UserInfoTest.java b/idp-oidc-extension-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/UserInfoTest.java
index ab976b4a..ab653fdc 100644
--- a/idp-oidc-extension-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/UserInfoTest.java
+++ b/idp-oidc-extension-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/UserInfoTest.java
@@ -93,6 +93,7 @@ public class UserInfoTest extends AbstractOidcApiFlowTest {
@AfterMethod
public void tearDown() throws IOException {
removeMetadata(storageService, clientId);
+ removeMetadata(storageService, clientIdActivateRevocationCondition);
}
@Test
@@ -821,6 +822,30 @@ public class UserInfoTest extends AbstractOidcApiFlowTest {
assertErrorCode(result, BearerTokenError.INVALID_TOKEN.getCode());
}
+ @Test
+ public void testFailsWithRevokedAccessToken() throws URISyntaxException, NoSuchAlgorithmException,
+ DataSealerException, ComponentInitializationException, IOException {
+ final String clientId = clientIdActivateRevocationCondition;
+ final BearerAccessToken token = buildToken(clientId, subject, new Scope("openid"));
+ storeMetadata(storageService, clientId, "mockSecret", scope);
+ request.addHeader("Authorization", getTokenHeaderValue(token));
+ final FlowExecutionResult result = flowExecutor.launchExecution(FLOW_ID, null, externalContext);
+ assertErrorCode(result, BearerTokenError.INVALID_TOKEN.getCode());
+ }
+
+ @Test
+ public void testFailsWithRevokedJWTAccessToken() throws URISyntaxException, NoSuchAlgorithmException,
+ DataSealerException, ComponentInitializationException, IOException, com.nimbusds.oauth2.sdk.ParseException,
+ JOSEException {
+ final String clientId = clientIdActivateRevocationCondition;
+ final BearerAccessToken token = buildJWTToken(clientId, subject , new Scope("openid"), null, signingKey.getPrivateKey(), "RS256");
+
+ storeMetadata(storageService, clientId, "mockSecret", scope);
+ request.addHeader("Authorization", getTokenHeaderValue(token));
+ final FlowExecutionResult result = flowExecutor.launchExecution(FLOW_ID, null, externalContext);
+ assertErrorCode(result, BearerTokenError.INVALID_TOKEN.getCode());
+ }
+
@Factory
public Object[] createUserInfoAsJwtSecurityTests() {
return new Object[] {
diff --git a/idp-oidc-extension-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/logic/AttributeTokenRevocationConditionTest.java b/idp-oidc-extension-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/logic/AttributeTokenRevocationConditionTest.java
new file mode 100644
index 00000000..11c866a1
--- /dev/null
+++ b/idp-oidc-extension-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/logic/AttributeTokenRevocationConditionTest.java
@@ -0,0 +1,167 @@
+/*
+ * 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.time.Instant;
+import java.util.Collection;
+import java.util.Date;
+import java.util.UUID;
+import java.util.stream.Collectors;
+
+import javax.annotation.Nonnull;
+
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.testng.Assert;
+import org.testng.annotations.AfterMethod;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+import com.nimbusds.jwt.JWTClaimsSet;
+
+import net.shibboleth.idp.attribute.IdPAttribute;
+import net.shibboleth.idp.attribute.StringAttributeValue;
+import net.shibboleth.idp.attribute.resolver.AttributeResolver;
+import net.shibboleth.idp.attribute.resolver.ResolutionException;
+import net.shibboleth.idp.attribute.resolver.context.AttributeResolutionContext;
+import net.shibboleth.idp.plugin.oidc.op.token.support.TokenClaimsSet;
+import net.shibboleth.shared.collection.CollectionSupport;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.logic.BiFunctionSupport;
+import net.shibboleth.shared.service.ReloadableService;
+import net.shibboleth.shared.service.ServiceableComponent;
+
+/**
+ * Unit tests for {@link AttributeTokenRevocationCondition}.
+ */
+public class AttributeTokenRevocationConditionTest {
+
+ private Collection<Instant> revocationsToResolve;
+
+ private AttributeTokenRevocationCondition condition;
+
+ @BeforeMethod
+ public void setUp() throws ComponentInitializationException {
+ condition = new AttributeTokenRevocationCondition();
+ condition.setPrincipalNameLookupStrategy(BiFunctionSupport.constant("jdoe"));
+ condition.setAttributeResolver(new MockResolver());
+ condition.setAttributeId("revocation");
+ condition.initialize();
+
+ }
+
+ @AfterMethod
+ public void tearDown() {
+ condition.destroy();
+ }
+
+
+ @Test
+ public void testNotRevoked() {
+ Assert.assertFalse(condition.test(new ProfileRequestContext(),
+ new JWTClaimsSet.Builder()
+ .claim(TokenClaimsSet.KEY_AUTH_TIME, new Date())
+ .jwtID(UUID.randomUUID().toString())
+ .build()));
+ }
+
+ @Test
+ public void testRevoked() {
+ revocationsToResolve = CollectionSupport.singletonList(Instant.now());
+ Assert.assertTrue(condition.test(new ProfileRequestContext(),
+ new JWTClaimsSet.Builder()
+ .claim(TokenClaimsSet.KEY_AUTH_TIME, Date.from(Instant.now().minusSeconds(3600)))
+ .jwtID(UUID.randomUUID().toString())
+ .build()));
+ }
+
+ @Test
+ public void testPastRevoked() {
+ revocationsToResolve = CollectionSupport.singletonList(Instant.now().minusSeconds(3600));
+ Assert.assertFalse(condition.test(new ProfileRequestContext(),
+ new JWTClaimsSet.Builder()
+ .claim(TokenClaimsSet.KEY_AUTH_TIME, Date.from(Instant.now()))
+ .jwtID(UUID.randomUUID().toString())
+ .build()));
+ }
+
+ /**
+ * Mock attribute source.
+ */
+ private class MockResolver implements ReloadableService<AttributeResolver> {
+
+ /** {@inheritDoc} */
+ public boolean isInitialized() {
+ return true;
+ }
+
+ /** {@inheritDoc} */
+ public void initialize() throws ComponentInitializationException {
+ }
+
+ /** {@inheritDoc} */
+ public Instant getLastSuccessfulReloadInstant() {
+ return null;
+ }
+
+ /** {@inheritDoc} */
+ public Instant getLastReloadAttemptInstant() {
+ return null;
+ }
+
+ /** {@inheritDoc} */
+ public Throwable getReloadFailureCause() {
+ return null;
+ }
+
+ /** {@inheritDoc} */
+ public void reload() {
+ }
+
+ /** {@inheritDoc} */
+ public @Nonnull ServiceableComponent<AttributeResolver> getServiceableComponent() {
+ return new ServiceableComponent<AttributeResolver>() {
+
+ public @Nonnull AttributeResolver getComponent() {
+ return new AttributeResolver() {
+
+ public String getId() {
+ return "test";
+ }
+
+ public void resolveAttributes(@Nonnull final AttributeResolutionContext resolutionContext)
+ throws ResolutionException {
+ if ("jdoe".equals(resolutionContext.getPrincipal()) && revocationsToResolve != null) {
+ final IdPAttribute attr = new IdPAttribute("revocation");
+ attr.setValues(
+ revocationsToResolve.stream()
+ .map(i -> StringAttributeValue.valueOf(Long.toString(i.getEpochSecond())))
+ .collect(Collectors.toUnmodifiableList())
+ );
+ resolutionContext.setResolvedIdPAttributes(CollectionSupport.singletonList(attr));
+ }
+ }
+ };
+ }
+
+ public void close() {
+ }
+
+ };
+ }
+
+ }
+
+
+}
diff --git a/idp-oidc-extension-impl/src/test/resources/net/shibboleth/idp/module/conf/attribute-resolver.xml b/idp-oidc-extension-impl/src/test/resources/net/shibboleth/idp/module/conf/attribute-resolver.xml
index 5d88aeef..2ee49ded 100644
--- a/idp-oidc-extension-impl/src/test/resources/net/shibboleth/idp/module/conf/attribute-resolver.xml
+++ b/idp-oidc-extension-impl/src/test/resources/net/shibboleth/idp/module/conf/attribute-resolver.xml
@@ -76,6 +76,17 @@
<AttributeEncoder xsi:type="oidc:OIDCString" name="sub" />
</AttributeDefinition>
+ <AttributeDefinition id="customRevocation" xsi:type="DateTime" epochInSeconds="false">
+ <InputAttributeDefinition ref="currentTimePlusHour"/>
+ </AttributeDefinition>
+
+ <AttributeDefinition id="currentTimePlusHour" xsi:type="ScriptedAttribute" dependencyOnly="true">
+ <Script><![CDATA[
+ currentTimePlusHour.addValue((new Date().getTime() + 3600000).toString());
+ ]]>
+ </Script>
+ </AttributeDefinition>
+
<!-- ========================================== -->
<!-- Data Connectors -->
<!-- ========================================== -->
diff --git a/idp-oidc-extension-impl/src/test/resources/net/shibboleth/idp/module/conf/global.xml b/idp-oidc-extension-impl/src/test/resources/net/shibboleth/idp/module/conf/global.xml
index 707f855f..26c6ba84 100644
--- a/idp-oidc-extension-impl/src/test/resources/net/shibboleth/idp/module/conf/global.xml
+++ b/idp-oidc-extension-impl/src/test/resources/net/shibboleth/idp/module/conf/global.xml
@@ -12,6 +12,16 @@
default-init-method="initialize"
default-destroy-method="destroy">
+ <bean id="CustomTokenRevocationCondition" parent="shibboleth.BiConditions.Expression"
+ c:expression="'mockClientIdActivateRevocationCondition'.equals(#custom.get('rp').apply(#input1)) and #custom.get('condition').test(#input1, #input2)">
+ <property name="customObject">
+ <util:map value-type="java.lang.Object">
+ <entry key="rp"><ref bean="shibboleth.RelyingPartyIdLookup.Simple"/></entry>
+ <entry key="condition"><ref bean="shibboleth.oauth2.revocationCondition.AttributeTokenRevocationCondition"/></entry>
+ </util:map>
+ </property>
+ </bean>
+
<util:list id="shibboleth.oidc.ClientSecretValueResolvers">
<bean parent="shibboleth.oidc.PropertiesClientSecretValueResolver"
p:resource="classpath:/net/shibboleth/idp/oidc/metadata/impl/client-secret-test.properties" />
diff --git a/idp-oidc-extension-impl/src/test/resources/net/shibboleth/idp/module/conf/oidc.properties b/idp-oidc-extension-impl/src/test/resources/net/shibboleth/idp/module/conf/oidc.properties
index d2e0c884..2897761d 100644
--- a/idp-oidc-extension-impl/src/test/resources/net/shibboleth/idp/module/conf/oidc.properties
+++ b/idp-oidc-extension-impl/src/test/resources/net/shibboleth/idp/module/conf/oidc.properties
@@ -22,4 +22,7 @@ idp.oidc.discovery.resolver.values = CustomConfigurationValues
idp.oidc.DefaultUnregisteredClientPolicyFile = src/test/resources/net/shibboleth/idp/module/conf/unregistered-policy.json
-idp.oauth2.jwtAuth.targetedEndpointAsJWTAudience = true
\ No newline at end of file
+idp.oauth2.jwtAuth.targetedEndpointAsJWTAudience = true
+idp.oauth2.revocationCondition = true
+idp.oauth2.revocationCondition.custom = CustomTokenRevocationCondition
+idp.oauth2.revocationCondition.attributeId = customRevocation
\ No newline at end of file
--
To stop receiving notification emails like this one, please contact
the administrator of this repository.
More information about the commits
mailing list