[java-plugin-shibd-oidc] 07/11: WIP: JSHIBDOIDC-34 - Add JWKS Storage Service Credential Resolver
Codeberg
noreply at shibboleth.net
Wed Oct 7 14:29:12 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/1119bfeaed838f8657c9818fb4a1da86ad574955
commit 1119bfeaed838f8657c9818fb4a1da86ad574955
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 d6085be..1f348b0 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