[java-plugin-shibd-oidc] 07/13: WIP: JSHIBDOIDC-34 - Add JWKS Storage Service Credential Resolver

Codeberg noreply at shibboleth.net
Fri Oct 16 14:48:47 UTC 2026


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

codeberg pushed a commit to branch dev/JSHIBDOIDC-31-cred-resolver
in repository java-plugin-shibd-oidc.

View the commit online:
https://codeberg.org/Shibboleth/java-plugin-shibd-oidc/commit/1b084bb8245c683e3252c209156bcf302ffdcbf0

commit 1b084bb8245c683e3252c209156bcf302ffdcbf0
Author: Phil Smart <philip.smart at jisc.ac.uk>
AuthorDate: Fri Oct 2 17:56:17 2026 +0100

    WIP: JSHIBDOIDC-34 - Add JWKS Storage Service Credential Resolver
    
     - Work in progress
     - Add JWKCredential storage service resolvers for single JWK and JWK
    Set representations
    
    https://shibboleth.atlassian.net/browse/JSHIBDOIDC-34
---
 .../sp/service/credentials/postconfig.xml          |  45 +++
 sp-oidc-impl/pom.xml                               |   6 +
 .../impl/ClientSecretCredentialResolver.java       |   4 +-
 .../impl/JWKCredentialStorageServiceResolver.java  | 134 ++++++++
 .../JWKSetCredentialStorageServiceResolver.java    | 195 +++++++++++
 .../JWKCredentialStorageServiceResolverTest.java   | 295 +++++++++++++++++
 ...JWKSetCredentialStorageServiceResolverTest.java | 365 +++++++++++++++++++++
 7 files changed, 1042 insertions(+), 2 deletions(-)

diff --git a/sp-oidc-conf-impl/src/main/resources/META-INF/net/shibboleth/sp/service/credentials/postconfig.xml b/sp-oidc-conf-impl/src/main/resources/META-INF/net/shibboleth/sp/service/credentials/postconfig.xml
index 56cc768..fcc1565 100644
--- a/sp-oidc-conf-impl/src/main/resources/META-INF/net/shibboleth/sp/service/credentials/postconfig.xml
+++ b/sp-oidc-conf-impl/src/main/resources/META-INF/net/shibboleth/sp/service/credentials/postconfig.xml
@@ -11,11 +11,54 @@
                            
        default-init-method="initialize"
        default-destroy-method="destroy">
+       
+
+    <!-- WIP: Additional resolver parent beans. -->
+
+    <bean id="shibboleth.sp.oidc.CredentialResolver.MapBased"
+            parent="shibboleth.sp.CredentialResolver.MapBased" abstract="true">
+        <property name="protocols">
+            <util:constant static-field="net.shibboleth.oidc.saml.xmlobject.Constants.OIDC_PROTOCOL_URI" />
+        </property>
+    </bean>
+    
+    <!-- Abstract JOSE JWK Credential Beans TODO: maybe move to credentials-system.xml -->
+    <bean id="shibboleth.sp.CredentialResolver.StorageBased.JWK"
+        class="net.shibboleth.sp.jose.credential.impl.JWKCredentialStorageService" abstract="true"
+        p:storageService-ref="shibboleth.sp.CredentialStorage.ReadOnly"
+        p:velocityEngine-ref="shibboleth.VelocityEngine"
+        p:contextTemplate="${protocol}" />
+    
+    
+    <bean id="shibboleth.sp.oidc.CredentialResolver.StorageBased.JWK"
+                    parent="shibboleth.sp.CredentialResolver.StorageBased.JWK" abstract="true">
+        <property name="protocols">
+            <util:constant static-field="net.shibboleth.oidc.saml.xmlobject.Constants.OIDC_PROTOCOL_URI" />
+        </property>
+        <property name="protocolMap">
+            <map>
+                <entry>
+                    <key>
+                        <util:constant static-field="net.shibboleth.oidc.saml.xmlobject.Constants.OIDC_PROTOCOL_URI" />
+                    </key>
+                    <value>oidc</value>
+                </entry>
+            </map>
+        </property>
+    </bean>
+    
+    <!-- END -->
+    
+    
+    
 
     <!-- Installs a "default" set of OIDC credentials into the resolver service. -->
     
     <bean id="shibboleth.sp.oidc.DefaultCredentialResolver" class="net.shibboleth.sp.credential.impl.StaticCredentialResolver"
             c:_0="#{getObject('shibboleth.sp.oidc.DefaultCredentials') ?: getObject('DefaultCredentials')}">
+        <property name="order">
+            <util:constant static-field="org.springframework.core.Ordered.LOWEST_PRECEDENCE" />
+        </property>
         <property name="protocols">
             <util:constant static-field="net.shibboleth.oidc.saml.xmlobject.Constants.OIDC_PROTOCOL_URI" />
         </property>
@@ -28,6 +71,8 @@
         </property>
     </bean>
     
+   
+    
     <bean id="DefaultClientSecretCredentials" class="org.springframework.beans.factory.config.ListFactoryBean" lazy-init="true">
         <property name="sourceList">
             <list>
diff --git a/sp-oidc-impl/pom.xml b/sp-oidc-impl/pom.xml
index c6ba6c0..4b754a5 100644
--- a/sp-oidc-impl/pom.xml
+++ b/sp-oidc-impl/pom.xml
@@ -147,6 +147,12 @@
             <scope>provided</scope>
         </dependency>
         
+        <dependency>
+            <groupId>${opensaml.groupId}</groupId>
+            <artifactId>opensaml-spring</artifactId>
+            <scope>provided</scope>
+        </dependency>
+        
         <dependency>
             <groupId>jakarta.servlet</groupId>
             <artifactId>jakarta.servlet-api</artifactId>
diff --git a/sp-oidc-impl/src/main/java/net/shibboleth/sp/jose/config/impl/ClientSecretCredentialResolver.java b/sp-oidc-impl/src/main/java/net/shibboleth/sp/jose/config/impl/ClientSecretCredentialResolver.java
index 654c653..f92fd33 100644
--- a/sp-oidc-impl/src/main/java/net/shibboleth/sp/jose/config/impl/ClientSecretCredentialResolver.java
+++ b/sp-oidc-impl/src/main/java/net/shibboleth/sp/jose/config/impl/ClientSecretCredentialResolver.java
@@ -37,10 +37,10 @@ import net.shibboleth.shared.resolver.ResolverException;
 import net.shibboleth.shared.service.ReloadableService;
 import net.shibboleth.shared.service.ServiceableComponent;
 import net.shibboleth.sp.Agent;
-import net.shibboleth.sp.AgentIDCriterion;
 import net.shibboleth.sp.Application;
-import net.shibboleth.sp.ApplicationIDCriterion;
 import net.shibboleth.sp.context.AgentRequestContext;
+import net.shibboleth.sp.criteria.AgentIDCriterion;
+import net.shibboleth.sp.criteria.ApplicationIDCriterion;
 
 /**
  * A {@link JOSEObjectCredentialResolver} that extracts a client_secret credential from the {@link CredentialResolver} 
diff --git a/sp-oidc-impl/src/main/java/net/shibboleth/sp/jose/credential/impl/JWKCredentialStorageServiceResolver.java b/sp-oidc-impl/src/main/java/net/shibboleth/sp/jose/credential/impl/JWKCredentialStorageServiceResolver.java
new file mode 100644
index 0000000..f44dc18
--- /dev/null
+++ b/sp-oidc-impl/src/main/java/net/shibboleth/sp/jose/credential/impl/JWKCredentialStorageServiceResolver.java
@@ -0,0 +1,134 @@
+/*
+ * 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.sp.jose.credential.impl;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+
+import javax.annotation.Nonnull;
+
+import org.apache.velocity.VelocityContext;
+import org.apache.velocity.exception.VelocityException;
+import org.opensaml.security.credential.Credential;
+import org.opensaml.storage.StorageRecord;
+import org.slf4j.Logger;
+import org.springframework.core.io.ByteArrayResource;
+
+import net.shibboleth.oidc.security.credential.BasicJWKCredentialFactoryBean;
+import net.shibboleth.oidc.security.credential.JWKCredential;
+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.ComponentInitializationException;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.primitive.LoggerFactory;
+import net.shibboleth.shared.primitive.StringSupport;
+import net.shibboleth.shared.resolver.CriteriaSet;
+import net.shibboleth.shared.resolver.ResolverException;
+import net.shibboleth.shared.velocity.Template;
+import net.shibboleth.sp.credential.AbstractStorageServiceCredentialResolver;
+
+/**
+* A specialized storage-based resolver that can resolve the {@link JWKCredential} type.
+*
+* <p>A single JWK storage record can contain public and or private key material (RFC 7517).</p>
+* 
+* <p>Does not currently deal with encrypted JWKs.</p>
+*/
+public class JWKCredentialStorageServiceResolver extends AbstractStorageServiceCredentialResolver<JWKCredential> {
+	
+	/** Class logger. */
+    @Nonnull private final Logger log = LoggerFactory.getLogger(JWKCredentialStorageServiceResolver.class);
+    
+    /** Template used to construct the storage key for the JSON Web Key. */
+    @Nonnull private String jwkTemplateString;
+    
+    /** Velocity template for the JWK storage key. */
+    @NonnullAfterInit private Template jwkTemplate;
+
+	public JWKCredentialStorageServiceResolver() {
+		super(JWKCredential.class);
+		jwkTemplateString = "sp-${usage}.jwk";
+	}
+	
+    /**
+     * Sets the Velocity template string for the JWK's storage key.
+     * 
+     * <p>Defaults to "sp-${usage}.jwk", which punts any other decoration to the storage context.</p>
+     * 
+     * @param template template string
+     */
+    public void setJwkTemplateString(@Nonnull @NotEmpty final String template) {
+        checkSetterPreconditions();
+        
+        jwkTemplateString = Constraint.isNotNull(StringSupport.trimOrNull(template),
+                "JSON Web Key template cannot be null or empty");
+    }
+    
+    /** {@inheritDoc} */
+    @Override
+    protected void doInitialize() throws ComponentInitializationException {
+        super.doInitialize();
+        
+        jwkTemplate = Template.fromTemplate(getVelocityEngine(), jwkTemplateString,
+                StandardCharsets.UTF_8);
+    }
+
+	@Override
+	protected Iterable<Credential> doResolve(final VelocityContext velocityContext, final CriteriaSet criteria,
+			final String storageContext) throws ResolverException {
+		
+		final StorageRecord<String> jwkData;
+
+        try {
+        	
+            final String keyStorageKey = jwkTemplate.merge(velocityContext);
+            log.debug("CredentialResolver {}: Resolved storage keys for key ({})", getId(), keyStorageKey);            
+            jwkData = getStorageService().read(storageContext, keyStorageKey);  
+            
+        } catch (final VelocityException e) {
+            throw new ResolverException("Error executing storage key template", e);
+        } catch (final IOException e) {
+            throw new ResolverException("I/O exception retrieving credential data", e);
+        }
+        
+        if (jwkData == null) {
+            log.debug("Credential Resolver {}: No JWK resolved", getId());
+            return CollectionSupport.emptyList();
+        }
+        
+        log.debug("CredentialResolver {}: Resolved data for JWK, constructing credential", getId());
+        
+        // Populate a factory bean from the resolved material.
+        
+        final BasicJWKCredentialFactoryBean factory = new BasicJWKCredentialFactoryBean();
+        factory.setResource(new ByteArrayResource(jwkData.getValue().getBytes(StandardCharsets.UTF_8)));
+        
+        try {
+            factory.afterPropertiesSet();
+            final JWKCredential credential = factory.getObject();
+            if (credential != null) {
+                log.debug("CredentialResolver {}: Resolved JWKCredential for caller", getId());
+                return CollectionSupport.singletonList(credential);
+            }
+            
+            log.warn("CredentialResolver {}: Factory bean returned null", getId());
+            return CollectionSupport.emptyList();
+        } catch (final Exception e) {
+            throw new ResolverException("Exception during creation of JWKCredential object", e);
+        }
+	}
+
+}
diff --git a/sp-oidc-impl/src/main/java/net/shibboleth/sp/jose/credential/impl/JWKSetCredentialStorageServiceResolver.java b/sp-oidc-impl/src/main/java/net/shibboleth/sp/jose/credential/impl/JWKSetCredentialStorageServiceResolver.java
new file mode 100644
index 0000000..2e02887
--- /dev/null
+++ b/sp-oidc-impl/src/main/java/net/shibboleth/sp/jose/credential/impl/JWKSetCredentialStorageServiceResolver.java
@@ -0,0 +1,195 @@
+/*
+ * 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.sp.jose.credential.impl;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.text.ParseException;
+import java.util.ArrayList;
+import java.util.List;
+
+import javax.annotation.Nonnull;
+
+import org.apache.velocity.VelocityContext;
+import org.apache.velocity.exception.VelocityException;
+import org.opensaml.security.credential.Credential;
+import org.opensaml.storage.StorageRecord;
+import org.slf4j.Logger;
+import org.springframework.core.io.ByteArrayResource;
+
+import com.nimbusds.jose.jwk.JWK;
+import com.nimbusds.jose.jwk.JWKMatcher;
+import com.nimbusds.jose.jwk.JWKSet;
+
+import net.shibboleth.oidc.security.credential.BasicJWKCredentialFactoryBean;
+import net.shibboleth.oidc.security.credential.JWKCredential;
+import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
+import net.shibboleth.shared.annotation.constraint.NotEmpty;
+import net.shibboleth.shared.annotation.constraint.NotLive;
+import net.shibboleth.shared.annotation.constraint.Unmodifiable;
+import net.shibboleth.shared.collection.CollectionSupport;
+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.resolver.CriteriaSet;
+import net.shibboleth.shared.resolver.ResolverException;
+import net.shibboleth.shared.velocity.Template;
+import net.shibboleth.sp.credential.AbstractStorageServiceCredentialResolver;
+
+/**
+* A specialized storage-based resolver that can resolve the {@link JWKCredential} type. Each storage record
+* represents a JWK Set.
+*
+* <p>Individual JWKs are resolved by key ID ('kid'). While different keys within a JWK Set SHOULD use distinct 'kid' 
+* values, uniqueness is not guaranteed, so multiple {@link JWKCredential}s may be resolved.</p>
+* 
+* <p>JWKs are required to contain a 'kid' to be resolvable by this resolver.</p>
+* 
+* <p>Does not currently deal with encrypted JWKs.</p>
+*/
+public class JWKSetCredentialStorageServiceResolver extends AbstractStorageServiceCredentialResolver<JWKCredential> {
+	
+	/** Class logger. */
+    @Nonnull private final Logger log = LoggerFactory.getLogger(JWKSetCredentialStorageServiceResolver.class);
+    
+    /** Template used to construct the storage key for the JWK Set. */
+    @Nonnull private String jwkTemplateString;
+    
+    /** Template used to construct the Key ID. */
+    @NonnullAfterInit private String kidTemplateString;
+    
+    /** Velocity template for the JWK Set storage key. */
+    @NonnullAfterInit private Template jwkTemplate;
+    
+    /** Velocity template for the JWK Key ID. */
+    @NonnullAfterInit private Template kidTemplate;
+
+	public JWKSetCredentialStorageServiceResolver() {
+		super(JWKCredential.class);
+		jwkTemplateString = "sp.jwks";
+		kidTemplateString = "${agentID}-${usage}";
+	}
+	
+    /**
+     * Sets the Velocity template string for the JWK Set storage key.
+     * 
+     * <p>Defaults to "sp.jwks", which punts any other decoration to the storage context.</p>
+     * 
+     * @param template template string
+     */
+    public void setJwkTemplateString(@Nonnull @NotEmpty final String template) {
+        checkSetterPreconditions();
+        
+        jwkTemplateString = Constraint.isNotNull(StringSupport.trimOrNull(template),
+                "JSON Web Key template cannot be null or empty");
+    }
+    
+    /**
+     * Set the Key ID Velocity template string for the key(s) to return from the KeySet.
+     * 
+     * <p>Defaults to "${agentID}-${usage}", which assumes per-Agent entries
+     * discriminated by usage.</p>
+     * 
+     * @param template template string
+     */
+    public void setKidTemplateString(@Nonnull @NotEmpty final String template) {
+    	checkSetterPreconditions();
+    	
+    	kidTemplateString = Constraint.isNotNull(StringSupport.trimOrNull(template),
+                "Key ID template cannot be null or empty");
+	}
+    
+    /** {@inheritDoc} */
+    @Override
+    protected void doInitialize() throws ComponentInitializationException {
+        super.doInitialize();
+        
+        jwkTemplate = Template.fromTemplate(getVelocityEngine(), jwkTemplateString,
+                StandardCharsets.UTF_8);
+        kidTemplate = Template.fromTemplate(getVelocityEngine(), kidTemplateString,
+                StandardCharsets.UTF_8);
+    }
+
+    /** {@inheritDoc} */
+	@Override
+	@Nonnull @NotLive @Unmodifiable protected Iterable<Credential> doResolve(final VelocityContext velocityContext, 
+			final CriteriaSet criteria, final String storageContext) throws ResolverException {
+		
+		final StorageRecord<String> jwksData;
+		final String kid;
+
+        try {
+        	
+            final String keyStorageKey = jwkTemplate.merge(velocityContext);
+            kid = kidTemplate.merge(velocityContext);
+            
+            log.debug("CredentialResolver {}: Resolved storage keys for key ({}), kid ({})", getId(), 
+            		keyStorageKey, kid);            
+            jwksData = getStorageService().read(storageContext, keyStorageKey);  
+            
+        } catch (final VelocityException e) {
+            throw new ResolverException("Error executing storage key template", e);
+        } catch (final IOException e) {
+            throw new ResolverException("I/O exception retrieving credential data", e);
+        }
+        
+        if (jwksData == null) {
+            log.debug("Credential Resolver {}: No JWKS resolved", getId());
+            return CollectionSupport.emptyList();
+        }
+        
+        log.debug("CredentialResolver {}: Resolved data for JWK Set, constructing credentials", getId());
+        
+        // Load the entire JWKS to find the correct key
+        final JWKSet jwks;
+		try {
+			jwks = JWKSet.parse(jwksData.getValue());
+		} catch (final ParseException e) {
+			throw new ResolverException("Exception during creation of JWK Set", e);
+		}		
+		
+		final JWKSet matchingKeys = jwks.filter(new JWKMatcher.Builder().keyID(kid).build());		
+		if (matchingKeys.isEmpty()) {
+			 log.debug("CredentialResolver {}: No JWK could be found with kid '{}'", getId(), kid);
+	         return CollectionSupport.emptyList();
+		}
+		
+		final List<JWKCredential> jwkCredentials = new ArrayList<>();
+		
+		for (final JWK key : matchingKeys.getKeys()) {
+			if (key != null) {
+				final BasicJWKCredentialFactoryBean factory = new BasicJWKCredentialFactoryBean();
+				// Need to serialize to be be compatible with the factory
+		        factory.setResource(new ByteArrayResource(key.toJSONString().getBytes(StandardCharsets.UTF_8)));
+		        try {
+			        factory.afterPropertiesSet();
+		            final JWKCredential credential = factory.getObject();
+		            if (credential != null) {
+		                log.debug("CredentialResolver {}: Resolved JWKCredential for caller", getId());
+		                jwkCredentials.add(credential);
+		            }
+		        } catch (final Exception e) {
+		        	throw new ResolverException("Exception during creation of JWKCredential", e);
+	            }
+			}
+		}
+		log.debug("CredentialResolver {}: Resolved '{}' JWKs from JWK Set and kid '{}'", 
+				getId(), jwkCredentials.size(), kid);
+		return CollectionSupport.copyToList(jwkCredentials);
+		
+	}
+
+}
diff --git a/sp-oidc-impl/src/test/java/net/shibboleth/sp/oidc/profile/credential/impl/JWKCredentialStorageServiceResolverTest.java b/sp-oidc-impl/src/test/java/net/shibboleth/sp/oidc/profile/credential/impl/JWKCredentialStorageServiceResolverTest.java
new file mode 100644
index 0000000..987fa60
--- /dev/null
+++ b/sp-oidc-impl/src/test/java/net/shibboleth/sp/oidc/profile/credential/impl/JWKCredentialStorageServiceResolverTest.java
@@ -0,0 +1,295 @@
+/*
+ * 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.sp.oidc.profile.credential.impl;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.DirectoryNotEmptyException;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.SimpleFileVisitor;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.security.PrivateKey;
+import java.security.PublicKey;
+import java.time.Duration;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.opensaml.core.criterion.EntityIdCriterion;
+import org.opensaml.security.credential.UsageType;
+import org.opensaml.security.criteria.UsageCriterion;
+import org.opensaml.security.crypto.KeySupport;
+import org.testng.Assert;
+import org.testng.annotations.AfterMethod;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+import com.nimbusds.jose.jwk.KeyUse;
+import com.nimbusds.jose.jwk.RSAKey;
+import com.nimbusds.jose.jwk.gen.RSAKeyGenerator;
+
+import net.shibboleth.oidc.security.credential.JWKCredential;
+import net.shibboleth.shared.resolver.CriteriaSet;
+import net.shibboleth.shared.testing.VelocityEngine;
+import net.shibboleth.sp.criteria.AgentIDCriterion;
+import net.shibboleth.sp.criteria.ApplicationIDCriterion;
+import net.shibboleth.sp.jose.credential.impl.JWKCredentialStorageServiceResolver;
+import net.shibboleth.sp.storage.impl.FilesystemStorageService;
+
+/**
+ * Tests for {@link JWKCredentialStorageServiceResolver}. 
+ */
+public class JWKCredentialStorageServiceResolverTest {
+	
+    private Path testRoot;
+
+    @BeforeMethod
+    public void setUp() throws IOException {
+        testRoot = Files.createTempDirectory("test-jwk-resolver");
+    }
+ 
+    private void tearDownWorker() throws IOException {
+        if (testRoot != null) {
+            Files.walkFileTree(testRoot, new SimpleFileVisitor<Path>() {
+                @Override
+                public FileVisitResult visitFile(final Path file, final BasicFileAttributes attrs)
+                    throws IOException
+                {
+                    Files.delete(file);
+                    return FileVisitResult.CONTINUE;
+                }
+                @Override
+                public FileVisitResult postVisitDirectory(final Path dir, final IOException e)
+                    throws IOException
+                {
+                    if (e == null) {
+                        Files.delete(dir);
+                        return FileVisitResult.CONTINUE;
+                    }
+                    // directory iteration failed
+                    throw e;
+                }
+            });
+            testRoot = null;
+        }
+    }
+
+    @AfterMethod
+    public void tearDown() throws IOException, InterruptedException {
+        try {
+            tearDownWorker();
+        } catch (final DirectoryNotEmptyException ex) {
+            // We hates the Microsoft Defender.  (it pins files so directories cannot be deleted)
+            Thread.sleep(10);
+            tearDownWorker();
+        }
+    }
+    
+    @Test
+    public void testStorageByAgent() throws Exception {
+        
+        // Prep directories for agents.
+        Files.createDirectories(Path.of(testRoot.toString(), "agents", "localhost"));
+        Files.createDirectories(Path.of(testRoot.toString(), "agents", "sp.example.org"));
+        
+        // Generate key for testing.
+        generateJWK(Path.of(testRoot.toString(), "agents", "localhost", "sp-signing.jwk"), KeyUse.SIGNATURE);
+        generateJWK(
+                Path.of(testRoot.toString(), "agents", "sp.example.org", "sp-encryption.jwk"),KeyUse.ENCRYPTION);
+        
+        final FilesystemStorageService storage = new FilesystemStorageService();
+        storage.setId("test");
+        storage.setReadOnly(true);
+        storage.setStorageBase(testRoot.toString());
+        storage.initialize();
+        
+        final JWKCredentialStorageServiceResolver resolver = new JWKCredentialStorageServiceResolver();
+        resolver.setId("test");
+        resolver.setStorageService(storage);
+        resolver.setVelocityEngine(VelocityEngine.newVelocityEngine());
+        resolver.setContextTemplate("agents/$agentID");
+        resolver.initialize();
+        
+        JWKCredential credential = (JWKCredential) resolver.resolveSingle(
+                buildCriteriaSet("localhost", "default", "https://op.example.org/", UsageType.SIGNING));
+        assert credential != null;
+        
+        PrivateKey privKey = credential.getPrivateKey();
+        assert privKey != null;
+        
+        PublicKey pubKey = credential.getPublicKey();
+        assert pubKey != null;
+
+        Assert.assertTrue(KeySupport.matchKeyPair(pubKey, privKey));
+        
+        credential = (JWKCredential) resolver.resolveSingle(
+                buildCriteriaSet("sp.example.org", "default", "https://op.example.org/", UsageType.ENCRYPTION));
+        assert credential != null;
+        
+        privKey = credential.getPrivateKey();
+        assert privKey != null;
+        
+        pubKey = credential.getPublicKey();
+        assert pubKey != null;
+        
+        Assert.assertTrue(KeySupport.matchKeyPair(pubKey, privKey));
+        
+        credential = (JWKCredential) resolver.resolveSingle(
+                buildCriteriaSet("missing-agent", "default", "https://op.example.org/", UsageType.ENCRYPTION));
+        Assert.assertNull(credential);
+    }
+    
+   
+    
+    /* Test credential lookup for a given Agent (likely OIDC client, and OP).*/
+    @Test
+    public void testStorageByAgentAndRelyingParty() throws Exception {
+        
+        // Prep directories for agents.
+    	// e73fd7b4d5d40e19ecd14b919b23fbd8ea99b748 sha-1 of https://op.example.org/
+    	final Path directory = Path.of(testRoot.toString(), "agents", "rp.example.org","e73fd7b4d5d40e19ecd14b919b23fbd8ea99b748");
+        Files.createDirectories(directory);
+        
+        // Generate key for testing.
+        generateJWK(Path.of(directory.toString(), "sp-signing.jwk"), KeyUse.SIGNATURE);
+        
+        final FilesystemStorageService storage = new FilesystemStorageService();
+        storage.setId("test");
+        storage.setReadOnly(true);
+        storage.setStorageBase(testRoot.toString());
+        storage.initialize();
+        
+        final JWKCredentialStorageServiceResolver resolver = new JWKCredentialStorageServiceResolver();
+        resolver.setId("test");
+        resolver.setStorageService(storage);
+        resolver.setVelocityEngine(VelocityEngine.newVelocityEngine());
+        resolver.setContextTemplate("agents/$agentID/$transformedRelyingPartyID");
+        resolver.initialize();
+        
+        final JWKCredential credential = (JWKCredential) resolver.resolveSingle(
+                buildCriteriaSet("rp.example.org", "default", "https://op.example.org/", UsageType.SIGNING));
+        assert credential != null;
+        
+        final PrivateKey privKey = credential.getPrivateKey();
+        assert privKey != null;
+        
+        final PublicKey pubKey = credential.getPublicKey();
+        assert pubKey != null;
+
+        Assert.assertTrue(KeySupport.matchKeyPair(pubKey, privKey));
+    }
+    
+    @Test
+    public void testCaching() throws Exception {
+        // Prep directories for agent.
+        Files.createDirectories(Path.of(testRoot.toString(), "agents", "localhost", "override"));
+        
+        // Generate JWK for testing.
+        final var jwkFile = Path.of(testRoot.toString(), "agents", "localhost", "override", "sp-signing.jwk");
+        generateJWK(jwkFile,KeyUse.SIGNATURE);
+        
+        final FilesystemStorageService storage = new FilesystemStorageService();
+        storage.setId("test");
+        storage.setReadOnly(true);
+        storage.setStorageBase(testRoot.toString());
+        storage.initialize();
+        
+        final JWKCredentialStorageServiceResolver resolver = new JWKCredentialStorageServiceResolver();
+        resolver.setId("test");
+        resolver.setStorageService(storage);
+        resolver.setVelocityEngine(VelocityEngine.newVelocityEngine());
+        resolver.setContextTemplate("agents/$agentID/$applicationID");
+        resolver.setCacheEnabled(true);
+        resolver.setExpireAfterAccess(Duration.ofSeconds(10));
+        resolver.initialize();
+        
+        JWKCredential credential = (JWKCredential) resolver.resolveSingle(
+                buildCriteriaSet("localhost", "override", "https://op.example.org/", UsageType.SIGNING));
+        assert credential != null;
+        
+        final PrivateKey privKey = credential.getPrivateKey();
+        assert privKey != null;
+        
+        final PublicKey pubKey = credential.getPublicKey();
+        assert pubKey != null;
+
+        Assert.assertTrue(KeySupport.matchKeyPair(pubKey, privKey));
+        
+        // Delete underlying files.
+        Files.delete(jwkFile);
+        
+        credential = (JWKCredential) resolver.resolveSingle(
+                buildCriteriaSet("localhost", "override", "https://op.example.org/", UsageType.SIGNING));
+        Assert.assertNotNull(credential);
+        
+        Thread.sleep(15000);
+        credential = (JWKCredential) resolver.resolveSingle(
+                buildCriteriaSet("localhost", "override", "https://op.example.org/", UsageType.SIGNING));
+        Assert.assertNull(credential);
+    }
+    
+    /**
+     * Generate a JWK private and public key pair. Does not generate a certificate for `x5c`. Save the JWK to file.
+     * 
+     * @param key the path where the key will be written
+     * @param use the KeyUse
+     * @throws Exception on error
+     */
+    private void generateJWK(@Nonnull final Path key, @Nonnull final KeyUse use) throws Exception {
+    	final RSAKey jwk = new RSAKeyGenerator(2048)
+                .keyID("test")
+                .keyUse(use)
+                .generate();
+    	
+    	Files.writeString(key, jwk.toJSONString(), StandardCharsets.UTF_8);
+    }
+    
+    /**
+     * Builds a set of criteria for the resolver.
+     * 
+     * @param agentID agent ID
+     * @param applicationID application ID
+     * @param entityID entityID
+     * @param usage usage type
+     * 
+     * @return built criteria
+     */
+    @Nonnull private CriteriaSet buildCriteriaSet(@Nullable final String agentID, @Nullable final String applicationID,
+            @Nullable final String entityID, @Nullable final UsageType usage) {
+        
+        final CriteriaSet criteria = new CriteriaSet();
+        
+        if (agentID != null) {
+            criteria.add(new AgentIDCriterion(agentID));
+        }
+
+        if (applicationID != null) {
+            criteria.add(new ApplicationIDCriterion(applicationID));
+        }
+        
+        if (entityID != null) {
+            criteria.add(new EntityIdCriterion(entityID));
+        }
+        
+        if (usage != null) {
+            criteria.add(new UsageCriterion(usage));
+        }
+        
+        return criteria;
+    }
+
+}
diff --git a/sp-oidc-impl/src/test/java/net/shibboleth/sp/oidc/profile/credential/impl/JWKSetCredentialStorageServiceResolverTest.java b/sp-oidc-impl/src/test/java/net/shibboleth/sp/oidc/profile/credential/impl/JWKSetCredentialStorageServiceResolverTest.java
new file mode 100644
index 0000000..3f4319e
--- /dev/null
+++ b/sp-oidc-impl/src/test/java/net/shibboleth/sp/oidc/profile/credential/impl/JWKSetCredentialStorageServiceResolverTest.java
@@ -0,0 +1,365 @@
+/*
+ * 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.sp.oidc.profile.credential.impl;
+
+import static org.testng.Assert.assertNull;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.DirectoryNotEmptyException;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.SimpleFileVisitor;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.security.PrivateKey;
+import java.security.PublicKey;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.opensaml.core.criterion.EntityIdCriterion;
+import org.opensaml.security.credential.UsageType;
+import org.opensaml.security.criteria.UsageCriterion;
+import org.opensaml.security.crypto.KeySupport;
+import org.testng.Assert;
+import org.testng.annotations.AfterMethod;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+import com.nimbusds.jose.jwk.JWK;
+import com.nimbusds.jose.jwk.JWKSet;
+import com.nimbusds.jose.jwk.KeyUse;
+import com.nimbusds.jose.jwk.RSAKey;
+import com.nimbusds.jose.jwk.gen.RSAKeyGenerator;
+
+import net.shibboleth.oidc.security.credential.JWKCredential;
+import net.shibboleth.shared.collection.CollectionSupport;
+import net.shibboleth.shared.collection.Pair;
+import net.shibboleth.shared.resolver.CriteriaSet;
+import net.shibboleth.shared.testing.VelocityEngine;
+import net.shibboleth.sp.criteria.AgentIDCriterion;
+import net.shibboleth.sp.criteria.ApplicationIDCriterion;
+import net.shibboleth.sp.jose.credential.impl.JWKSetCredentialStorageServiceResolver;
+import net.shibboleth.sp.storage.impl.FilesystemStorageService;
+
+/**
+ * Tests for {@link JWKSetCredentialStorageServiceResolver}. 
+ */
+public class JWKSetCredentialStorageServiceResolverTest {
+	
+    private Path testRoot;
+
+    @BeforeMethod
+    public void setUp() throws IOException {
+        testRoot = Files.createTempDirectory("test-jwk-resolver");
+    }
+ 
+    private void tearDownWorker() throws IOException {
+        if (testRoot != null) {
+            Files.walkFileTree(testRoot, new SimpleFileVisitor<Path>() {
+                @Override
+                public FileVisitResult visitFile(final Path file, final BasicFileAttributes attrs)
+                    throws IOException
+                {
+                    Files.delete(file);
+                    return FileVisitResult.CONTINUE;
+                }
+                @Override
+                public FileVisitResult postVisitDirectory(final Path dir, final IOException e)
+                    throws IOException
+                {
+                    if (e == null) {
+                        Files.delete(dir);
+                        return FileVisitResult.CONTINUE;
+                    }
+                    // directory iteration failed
+                    throw e;
+                }
+            });
+            testRoot = null;
+        }
+    }
+
+    @AfterMethod
+    public void tearDown() throws IOException, InterruptedException {
+        try {
+            tearDownWorker();
+        } catch (final DirectoryNotEmptyException ex) {
+            // We hates the Microsoft Defender.  (it pins files so directories cannot be deleted)
+            Thread.sleep(10);
+            tearDownWorker();
+        }
+    }
+    
+    @Test
+    public void testStorageByAgent_TwoKeys() throws Exception {
+        
+        // Prep directory for agents.
+        Files.createDirectories(Path.of(testRoot.toString(), "agents"));
+        
+        // Generate keys for testing.
+        generateJWKS(Path.of(testRoot.toString(), "agents", "sp.jwks"), 
+        		CollectionSupport.listOf(
+        				new Pair<KeyUse,String>(KeyUse.SIGNATURE, "localhost-signing"),
+        				new Pair<KeyUse,String>(KeyUse.ENCRYPTION, "sp.example.org-encryption")));
+        
+        final FilesystemStorageService storage = new FilesystemStorageService();
+        storage.setId("test");
+        storage.setReadOnly(true);
+        storage.setStorageBase(testRoot.toString());
+        storage.initialize();
+        
+        final JWKSetCredentialStorageServiceResolver resolver = new JWKSetCredentialStorageServiceResolver();
+        resolver.setId("test");
+        resolver.setStorageService(storage);
+        resolver.setVelocityEngine(VelocityEngine.newVelocityEngine());
+        resolver.setContextTemplate("agents");
+        resolver.setKidTemplateString("$agentID-${usage}");
+        resolver.initialize();
+        
+        JWKCredential credential = (JWKCredential) resolver.resolveSingle(
+                buildCriteriaSet("localhost", "default", "https://op.example.org/", UsageType.SIGNING));
+        assert credential != null;
+        
+        PrivateKey privKey = credential.getPrivateKey();
+        assert privKey != null;
+        
+        PublicKey pubKey = credential.getPublicKey();
+        assert pubKey != null;
+
+        Assert.assertTrue(KeySupport.matchKeyPair(pubKey, privKey));
+        
+        credential = (JWKCredential) resolver.resolveSingle(
+                buildCriteriaSet("sp.example.org", "default", "https://op.example.org/", UsageType.ENCRYPTION));
+        assert credential != null;
+        
+        privKey = credential.getPrivateKey();
+        assert privKey != null;
+        
+        pubKey = credential.getPublicKey();
+        assert pubKey != null;
+        
+        Assert.assertTrue(KeySupport.matchKeyPair(pubKey, privKey));
+        
+        credential = (JWKCredential) resolver.resolveSingle(
+                buildCriteriaSet("missing-agent", "default", "https://op.example.org/", UsageType.ENCRYPTION));
+        Assert.assertNull(credential);
+    
+    }
+    
+    @Test
+    public void testStorageByAgent_OneKey() throws Exception {
+        
+        // Prep directory for agents.
+        Files.createDirectories(Path.of(testRoot.toString(), "agents"));
+        
+        // Generate keys for testing.
+        generateJWKS(Path.of(testRoot.toString(), "agents", "sp.jwks"), 
+        		CollectionSupport.listOf(
+        				new Pair<KeyUse,String>(KeyUse.SIGNATURE, "localhost-signing")));
+        
+        final FilesystemStorageService storage = new FilesystemStorageService();
+        storage.setId("test");
+        storage.setReadOnly(true);
+        storage.setStorageBase(testRoot.toString());
+        storage.initialize();
+        
+        final JWKSetCredentialStorageServiceResolver resolver = new JWKSetCredentialStorageServiceResolver();
+        resolver.setId("test");
+        resolver.setStorageService(storage);
+        resolver.setVelocityEngine(VelocityEngine.newVelocityEngine());
+        resolver.setContextTemplate("agents");
+        resolver.setKidTemplateString("$agentID-${usage}");
+        resolver.initialize();
+        
+        JWKCredential credential = (JWKCredential) resolver.resolveSingle(
+                buildCriteriaSet("localhost", "default", "https://op.example.org/", UsageType.SIGNING));
+        assert credential != null;
+        
+        final PrivateKey privKey = credential.getPrivateKey();
+        assert privKey != null;
+        
+        final PublicKey pubKey = credential.getPublicKey();
+        assert pubKey != null;
+
+        Assert.assertTrue(KeySupport.matchKeyPair(pubKey, privKey));
+        
+        credential = (JWKCredential) resolver.resolveSingle(
+                buildCriteriaSet("sp.example.org", "default", "https://op.example.org/", UsageType.ENCRYPTION));
+        assertNull(credential);
+    
+    }
+    
+    @Test
+    public void testStorageByAgent_TwoKeys_SameKid() throws Exception {
+        
+        // Prep directory for agents.
+        Files.createDirectories(Path.of(testRoot.toString(), "agents"));
+        
+        // Generate keys for testing. Same KID, which is allowed, both will be returned by the resolver, and then 
+        // one from resolveSingle
+        generateJWKS(Path.of(testRoot.toString(), "agents", "sp.jwks"), 
+        		CollectionSupport.listOf(
+        				new Pair<KeyUse,String>(KeyUse.SIGNATURE, "localhost-signing"),
+        				new Pair<KeyUse,String>(KeyUse.SIGNATURE, "localhost-signing")));
+        
+        final FilesystemStorageService storage = new FilesystemStorageService();
+        storage.setId("test");
+        storage.setReadOnly(true);
+        storage.setStorageBase(testRoot.toString());
+        storage.initialize();
+        
+        final JWKSetCredentialStorageServiceResolver resolver = new JWKSetCredentialStorageServiceResolver();
+        resolver.setId("test");
+        resolver.setStorageService(storage);
+        resolver.setVelocityEngine(VelocityEngine.newVelocityEngine());
+        resolver.setContextTemplate("agents");
+        resolver.setKidTemplateString("$agentID-${usage}");
+        resolver.initialize();
+        
+        final JWKCredential credential = (JWKCredential) resolver.resolveSingle(
+                buildCriteriaSet("localhost", "default", "https://op.example.org/", UsageType.SIGNING));
+        assert credential != null;
+        
+        final PrivateKey privKey = credential.getPrivateKey();
+        assert privKey != null;
+        
+        final PublicKey pubKey = credential.getPublicKey();
+        assert pubKey != null;
+
+        Assert.assertTrue(KeySupport.matchKeyPair(pubKey, privKey));
+    
+    }
+    
+    @Test
+    public void testCaching_TwoKeys() throws Exception {
+        
+        // Prep directory for agents.
+        Files.createDirectories(Path.of(testRoot.toString(), "agents"));
+        
+        // Generate keys for testing.
+        final Path jwksFile = Path.of(testRoot.toString(), "agents", "sp.jwks");
+        generateJWKS(jwksFile, 
+        		CollectionSupport.listOf(
+        				new Pair<KeyUse,String>(KeyUse.SIGNATURE, "localhost-signing"),
+        				new Pair<KeyUse,String>(KeyUse.ENCRYPTION, "sp.example.org-encryption")));
+        
+        final FilesystemStorageService storage = new FilesystemStorageService();
+        storage.setId("test");
+        storage.setReadOnly(true);
+        storage.setStorageBase(testRoot.toString());
+        storage.initialize();
+        
+        final JWKSetCredentialStorageServiceResolver resolver = new JWKSetCredentialStorageServiceResolver();
+        resolver.setId("test");
+        resolver.setStorageService(storage);
+        resolver.setVelocityEngine(VelocityEngine.newVelocityEngine());
+        resolver.setContextTemplate("agents");
+        resolver.setKidTemplateString("$agentID-${usage}");
+        resolver.setCacheEnabled(true);
+        resolver.setExpireAfterAccess(Duration.ofSeconds(10));
+        resolver.initialize();
+        
+        JWKCredential credential = (JWKCredential) resolver.resolveSingle(
+                buildCriteriaSet("localhost", "override", "https://op.example.org/", UsageType.SIGNING));
+        assert credential != null;
+        
+        final PrivateKey privKey = credential.getPrivateKey();
+        assert privKey != null;
+        
+        final PublicKey pubKey = credential.getPublicKey();
+        assert pubKey != null;
+
+        Assert.assertTrue(KeySupport.matchKeyPair(pubKey, privKey));
+        
+        // Delete underlying file.
+        Files.delete(jwksFile);
+        
+        credential = (JWKCredential) resolver.resolveSingle(
+                buildCriteriaSet("localhost", "override", "https://op.example.org/", UsageType.SIGNING));
+        Assert.assertNotNull(credential);
+        
+        Thread.sleep(15000);
+        credential = (JWKCredential) resolver.resolveSingle(
+                buildCriteriaSet("localhost", "override", "https://op.example.org/", UsageType.SIGNING));
+        Assert.assertNull(credential);
+    
+    }
+
+
+    
+    /**
+     * Generate a number of JWK private and public key pairs. 
+     * Does not generate a certificate for `x5c`. Saved as a JSON Web Key Set file.
+     * 
+     * @param keyPath the path where the key will be written
+     * @param keys the keys to generate. A pair of KeyUsage,KeyID. 
+     * 
+     * @throws Exception on error
+     */
+    private void generateJWKS(@Nonnull final Path keyPath, @Nonnull final List<Pair<KeyUse, String>> keys) 
+    		throws Exception {
+    	
+    	final List<JWK> keysetKeys = new ArrayList<>();
+    	for (final Pair<KeyUse, String> key: keys) {
+    		final RSAKey jwk = new RSAKeyGenerator(2048)
+                    .keyID(key.getSecond())
+                    .keyUse(key.getFirst())
+                    .generate();
+    		keysetKeys.add(jwk);
+    	}
+    	final JWKSet jwks = new JWKSet(keysetKeys);    	
+    	Files.writeString(keyPath, jwks.toString(false), StandardCharsets.UTF_8);
+    }
+    
+    /**
+     * Builds a set of criteria for the resolver.
+     * 
+     * @param agentID agent ID
+     * @param applicationID application ID
+     * @param entityID entityID
+     * @param usage usage type
+     * 
+     * @return built criteria
+     */
+    @Nonnull private CriteriaSet buildCriteriaSet(@Nullable final String agentID, @Nullable final String applicationID,
+            @Nullable final String entityID, @Nullable final UsageType usage) {
+        
+        final CriteriaSet criteria = new CriteriaSet();
+        
+        if (agentID != null) {
+            criteria.add(new AgentIDCriterion(agentID));
+        }
+
+        if (applicationID != null) {
+            criteria.add(new ApplicationIDCriterion(applicationID));
+        }
+        
+        if (entityID != null) {
+            criteria.add(new EntityIdCriterion(entityID));
+        }
+        
+        if (usage != null) {
+            criteria.add(new UsageCriterion(usage));
+        }
+        
+        return criteria;
+    }
+
+}

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


More information about the commits mailing list