[java-idp-plugin-vci] branch main updated: Issuer configuration published by specific flow instead of only static files. Credential configuration has now resolver instead of reading it only from a file. Initial decorator for publishing statements in openid federation configuration.
Codeberg
noreply at shibboleth.net
Wed Sep 2 12:51:17 UTC 2026
This is an automated email from the git hooks/post-receive script.
codeberg pushed a commit to branch main
in repository java-idp-plugin-vci.
View the commit online:
https://codeberg.org/Shibboleth/java-idp-plugin-vci/commit/01867fa9d9614194121a474f92e6e044aaa0f029
The following commit(s) were added to refs/heads/main by this push:
new 01867fa Issuer configuration published by specific flow instead of only static files. Credential configuration has now resolver instead of reading it only from a file. Initial decorator for publishing statements in openid federation configuration.
01867fa is described below
commit 01867fa9d9614194121a474f92e6e044aaa0f029
Author: Janne Lauros <janne.lauros at csc.fi>
AuthorDate: Wed Sep 2 15:51:00 2026 +0300
Issuer configuration published by specific flow instead of only static files. Credential configuration has now resolver instead of reading it only from a file. Initial decorator for publishing statements in openid federation configuration.
---
openid-vci-api/pom.xml | 5 +
.../credential/CredentialConfigurations.java | 92 +++++--
.../resolver/CredentialConfigurationsResolver.java | 31 +++
...efreshableCredentialConfigurationsResolver.java | 57 ++++
.../metadata/CredentialIssuerMetadata.java | 135 +++++++++
.../resolver/CredentialIssuerMetadataResolver.java | 31 +++
.../CredentialIssuerMetadataValueResolver.java | 30 ++
...hableCredentialIssuerMetadataValueResolver.java | 57 ++++
.../credential/CredentialConfigurationsTest.java | 37 ++-
.../metadata/CredentialIssuerMetadataTest.java | 115 ++++++++
openid-vci-impl/pom.xml | 20 ++
...efaultOpenIDVCIIssuerMetadataConfiguration.java | 49 ++++
...FilesystemCredentialConfigurationsResolver.java | 110 ++++++++
.../CredentialIssuerMetadataSuccessResponse.java | 67 +++++
.../CredentialConfigurationsValueResolver.java | 120 ++++++++
...FilesystemCredentialIssuerMetadataResolver.java | 131 +++++++++
...FilesystemCredentialIssuerMetadataResolver.java | 300 ++++++++++++++++++++
...IssuerEntityConfigurationMetadataDecorator.java | 306 +++++++++++++++++++++
.../impl/AbstractCredentialValidationAction.java | 57 ++--
.../FormOutboundIssuerMetadataResponseMessage.java | 140 ++++++++++
.../META-INF/net.shibboleth.idp/postconfig.xml | 68 +++++
.../credential-offer/credential-offer-beans.xml | 2 +-
.../openid/vci/credentials/credentials-beans.xml | 2 +-
.../vci/issuer-metadata/issuer-metadata-beans.xml | 39 +++
.../vci/issuer-metadata/issuer-metadata-flow.xml | 27 ++
.../idp/service/relying-party/postconfig.xml | 8 +
.../openidvci/conf/openid-vci-credentials.xml | 112 +++++---
.../plugin/openidvci/conf/openid-vci-oidfed.xml | 101 +++++++
.../plugin/openidvci/conf/openid-vci.properties | 35 ++-
.../shibboleth/plugin/openidvci/module.properties | 18 +-
.../openidvci/static/openid-credential-issuer.json | 14 +
...systemCredentialConfigurationsResolverTest.java | 99 +++++++
.../CredentialConfigurationsValueResolverTest.java | 119 ++++++++
...systemCredentialIssuerMetadataResolverTest.java | 246 +++++++++++++++++
...erEntityConfigurationMetadataDecoratorTest.java | 277 +++++++++++++++++++
...mOutboundIssuerMetadataResponseMessageTest.java | 163 +++++++++++
.../impl/ValidateCredentialOfferingTest.java | 60 +++-
.../conf/no-credential-configurations.json | 3 +
.../conf/unparsable-credential-configuration.json | 8 +
.../src/test/resources/credentials/sealer.jks | Bin 500 -> 0 bytes
.../src/test/resources/credentials/sealer.kver | 1 -
.../openid-credential-issuer-no-issuer.json | 3 +
.../openid-credential-issuer-template.json | 14 +
.../openid-credential-issuer-unresolved.json | 4 +
.../metadata/openid-credential-issuer.json | 14 +
pom.xml | 3 +
46 files changed, 3232 insertions(+), 98 deletions(-)
diff --git a/openid-vci-api/pom.xml b/openid-vci-api/pom.xml
index 8a63ba0..bb26ddc 100644
--- a/openid-vci-api/pom.xml
+++ b/openid-vci-api/pom.xml
@@ -45,6 +45,11 @@
<artifactId>jackson-databind</artifactId>
<scope>provided</scope>
</dependency>
+ <dependency>
+ <groupId>${opensaml.groupId}</groupId>
+ <artifactId>opensaml-profile-api</artifactId>
+ <scope>provided</scope>
+ </dependency>
<!-- test dependencies -->
<dependency>
<groupId>org.testng</groupId>
diff --git a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialConfigurations.java b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialConfigurations.java
index 50b8465..bfc2316 100644
--- a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialConfigurations.java
+++ b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialConfigurations.java
@@ -17,21 +17,21 @@
package org.geant.shibboleth.plugin.openidvci.credential;
import java.io.IOException;
+import java.util.Collections;
+import java.util.LinkedHashMap;
import java.util.Map;
import javax.annotation.Nonnull;
+import org.geant.shibboleth.plugin.openidvci.metadata.CredentialIssuerMetadata;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
-import net.shibboleth.shared.annotation.constraint.NotEmpty;
-import net.shibboleth.shared.resource.Resource;
-
/**
- * Parses supported credential configurations.
+ * Supported credential configurations.
*
* See
* {@link https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-issuer-metadata-p}
@@ -39,6 +39,10 @@ import net.shibboleth.shared.resource.Resource;
*
* Parsed credential configuration is published to clients and used as a
* instruction on how credentials are wrapped to desired format.
+ *
+ * Both representations of the same source are kept. The parsed one instructs
+ * how a credential is formed, the source one is what gets published, so that a
+ * member this plugin does not model still reaches the wallet.
*/
public class CredentialConfigurations {
@@ -46,18 +50,30 @@ public class CredentialConfigurations {
@Nonnull
private static Logger log = LoggerFactory.getLogger(CredentialConfigurations.class);
+ /** Parser of the source. */
+ @Nonnull
+ private static final ObjectMapper mapper = new ObjectMapper();
+
/** Credential configuration content. */
@Nonnull
private final Map<String, CredentialConfiguration> credentialConfigurations;
+ /** Credential configuration content as it was written. */
+ @Nonnull
+ private final Map<String, Object> supported;
+
/**
* Constructor .
*
- * @param configurations Credential Configuration content
+ * @param credentialConfigurations Credential Configuration content
+ * @param source Credential Configuration content as it was
+ * written
*/
- private CredentialConfigurations(@Nonnull Map<String, CredentialConfiguration> credentialConfigurations) {
+ private CredentialConfigurations(@Nonnull Map<String, CredentialConfiguration> credentialConfigurations,
+ @Nonnull Map<String, Object> source) {
assert credentialConfigurations != null;
this.credentialConfigurations = credentialConfigurations;
+ supported = source;
}
public Map<String, CredentialConfiguration> getCredentialConfigurations() {
@@ -65,23 +81,59 @@ public class CredentialConfigurations {
}
/**
- * Parses credential configuration from resource.
+ * Get credential configuration content as it was written.
+ *
+ * This is the value of the 'credential_configurations_supported' member as
+ * published, it is not rewritten to what this plugin models.
*
- * @param resource Credential configuration
+ * @return unmodifiable content, in the order it was written
+ */
+ @Nonnull
+ public Map<String, Object> getSupported() {
+ return supported;
+ }
+
+ /**
+ * Parses credential configuration.
+ *
+ * @param bytes Credential configuration
* @return CredentialConfigurations instance.
- * @throws IOException if something unexpected occurs.
+ * @throws IOException if the source is not a JSON object, if it declares no
+ * credential configuration, or if a configuration cannot be
+ * parsed
*/
- public static CredentialConfigurations parse(@Nonnull @NotEmpty final Resource resource) throws IOException {
-
- assert resource != null;
- assert resource.exists();
- Map<String, Object> configurations = new ObjectMapper().readValue(
- new String(resource.getInputStream().readAllBytes()), new TypeReference<Map<String, Object>>() {
- });
- return new CredentialConfigurations(new ObjectMapper().readValue(
- new ObjectMapper().writeValueAsString(configurations.get("credential_configurations_supported")),
- new TypeReference<Map<String, CredentialConfiguration>>() {
- }));
+ @Nonnull
+ public static CredentialConfigurations parse(@Nonnull final byte[] bytes) throws IOException {
+
+ final Map<String, Object> document = mapper.readValue(bytes, new TypeReference<LinkedHashMap<String, Object>>() {
+ });
+ if (document == null) {
+ throw new IOException("Credential configuration is not a JSON object");
+ }
+
+ final Object configurations = document.get(CredentialIssuerMetadata.CREDENTIAL_CONFIGURATIONS_SUPPORTED);
+ if (!(configurations instanceof Map)) {
+ throw new IOException("Credential configuration has no '"
+ + CredentialIssuerMetadata.CREDENTIAL_CONFIGURATIONS_SUPPORTED + "' object");
+ }
+ if (((Map<?, ?>) configurations).isEmpty()) {
+ throw new IOException("Credential configuration declares no credential configuration");
+ }
+
+ final Map<String, CredentialConfiguration> parsed;
+ try {
+ parsed = mapper.convertValue(configurations, new TypeReference<Map<String, CredentialConfiguration>>() {
+ });
+ } catch (final IllegalArgumentException e) {
+ throw new IOException("Unable to parse a credential configuration: " + e.getMessage(), e);
+ }
+
+ log.debug("Parsed {} credential configuration(s): {}", parsed.size(), parsed.keySet());
+
+ @SuppressWarnings("unchecked")
+ final Map<String, Object> source = (Map<String, Object>) configurations;
+ return new CredentialConfigurations(Collections.unmodifiableMap(parsed),
+ Collections.unmodifiableMap(source));
}
}
diff --git a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/resolver/CredentialConfigurationsResolver.java b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/resolver/CredentialConfigurationsResolver.java
new file mode 100644
index 0000000..365f796
--- /dev/null
+++ b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/resolver/CredentialConfigurationsResolver.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * 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 org.geant.shibboleth.plugin.openidvci.credential.resolver;
+
+import org.geant.shibboleth.plugin.openidvci.credential.CredentialConfigurations;
+import org.opensaml.profile.context.ProfileRequestContext;
+
+import net.shibboleth.shared.component.IdentifiedComponent;
+import net.shibboleth.shared.resolver.Resolver;
+
+/**
+ * Resolver of the {@link CredentialConfigurations} this deployment issues from.
+ */
+public interface CredentialConfigurationsResolver
+ extends Resolver<CredentialConfigurations, ProfileRequestContext>, IdentifiedComponent {
+
+}
diff --git a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/resolver/RefreshableCredentialConfigurationsResolver.java b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/resolver/RefreshableCredentialConfigurationsResolver.java
new file mode 100644
index 0000000..a2c0d7c
--- /dev/null
+++ b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/resolver/RefreshableCredentialConfigurationsResolver.java
@@ -0,0 +1,57 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * 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 org.geant.shibboleth.plugin.openidvci.credential.resolver;
+
+import java.time.Instant;
+
+import javax.annotation.Nullable;
+
+import net.shibboleth.shared.resolver.ResolverException;
+
+/**
+ * Specialization of {@link CredentialConfigurationsResolver} that supports
+ * on-demand refresh.
+ */
+public interface RefreshableCredentialConfigurationsResolver extends CredentialConfigurationsResolver {
+
+ /**
+ * Refresh the data exposed by the resolver.
+ *
+ * @throws ResolverException if the refresh operation was unsuccessful
+ */
+ void refresh() throws ResolverException;
+
+ /**
+ * Get the time the last refresh cycle occurred.
+ *
+ * @return time the last refresh cycle occurred
+ */
+ @Nullable
+ Instant getLastRefresh();
+
+ /**
+ * Get the time the currently available configurations were last updated. This
+ * may be earlier than {@link #getLastRefresh()}, a refresh cycle that found
+ * nothing changed does not update it.
+ *
+ * @return time the currently available configurations were last updated, or
+ * null if they have never been read in successfully
+ */
+ @Nullable
+ Instant getLastUpdate();
+
+}
diff --git a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/CredentialIssuerMetadata.java b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/CredentialIssuerMetadata.java
new file mode 100644
index 0000000..bc54c94
--- /dev/null
+++ b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/CredentialIssuerMetadata.java
@@ -0,0 +1,135 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * 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 org.geant.shibboleth.plugin.openidvci.metadata;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import com.nimbusds.oauth2.sdk.ParseException;
+import com.nimbusds.oauth2.sdk.id.Issuer;
+
+/**
+ * Credential Issuer metadata as in <a href=
+ * "https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-issuer-metadata">
+ * OpenID4VCI</a>.
+ *
+ * Members are carried as they were parsed, this is the document that gets
+ * published. Only 'credential_issuer' is parsed, it names the document.
+ * Everything else is the deployer's to write.
+ */
+public class CredentialIssuerMetadata {
+
+ /** Member naming the Credential Issuer. */
+ @Nonnull
+ public static final String CREDENTIAL_ISSUER = "credential_issuer";
+
+ /** Member naming the Credential Endpoint. */
+ @Nonnull
+ public static final String CREDENTIAL_ENDPOINT = "credential_endpoint";
+
+ /** Member carrying the Credential Configurations. */
+ @Nonnull
+ public static final String CREDENTIAL_CONFIGURATIONS_SUPPORTED = "credential_configurations_supported";
+
+ /** Members of the document, in the order they were parsed. */
+ @Nonnull
+ private final Map<String, Object> members;
+
+ /** Credential Issuer the document is for. */
+ @Nonnull
+ private final Issuer credentialIssuer;
+
+ /**
+ * Constructor.
+ *
+ * @param parsed members of the document
+ * @param issuer Credential Issuer the document is for
+ */
+ private CredentialIssuerMetadata(@Nonnull final Map<String, Object> parsed, @Nonnull final Issuer issuer) {
+ members = parsed;
+ credentialIssuer = issuer;
+ }
+
+ /**
+ * Get the Credential Issuer the document is for.
+ *
+ * @return the Credential Issuer
+ */
+ @Nonnull
+ public Issuer getCredentialIssuer() {
+ return credentialIssuer;
+ }
+
+ /**
+ * Get the members of the document.
+ *
+ * @return unmodifiable members, in the order they were parsed
+ */
+ @Nonnull
+ public Map<String, Object> getMembers() {
+ return members;
+ }
+
+ /**
+ * Get a modifiable copy of the members, to build another document out of this
+ * one.
+ *
+ * @return copy of the members, in the order they were parsed
+ */
+ @Nonnull
+ public Map<String, Object> toModifiableMembers() {
+ return new LinkedHashMap<>(members);
+ }
+
+ /**
+ * Get whether the document has a member.
+ *
+ * @param member member to look for
+ * @return whether the member is present
+ */
+ public boolean hasMember(@Nonnull final String member) {
+ return members.containsKey(member);
+ }
+
+ /**
+ * Parse a document.
+ *
+ * @param members members of the document
+ * @return the document
+ * @throws ParseException if the members do not name a Credential Issuer
+ */
+ @Nonnull
+ public static CredentialIssuerMetadata parse(@Nullable final Map<String, Object> members) throws ParseException {
+
+ if (members == null) {
+ throw new ParseException("Credential Issuer metadata cannot be null");
+ }
+
+ final Object issuer = members.get(CREDENTIAL_ISSUER);
+ if (!(issuer instanceof String) || ((String) issuer).isEmpty()) {
+ throw new ParseException(
+ "Credential Issuer metadata must have a non empty '" + CREDENTIAL_ISSUER + "' member");
+ }
+ return new CredentialIssuerMetadata(Collections.unmodifiableMap(new LinkedHashMap<>(members)),
+ new Issuer((String) issuer));
+ }
+
+}
diff --git a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/resolver/CredentialIssuerMetadataResolver.java b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/resolver/CredentialIssuerMetadataResolver.java
new file mode 100644
index 0000000..ce19565
--- /dev/null
+++ b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/resolver/CredentialIssuerMetadataResolver.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * 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 org.geant.shibboleth.plugin.openidvci.metadata.resolver;
+
+import org.geant.shibboleth.plugin.openidvci.metadata.CredentialIssuerMetadata;
+import org.opensaml.profile.context.ProfileRequestContext;
+
+import net.shibboleth.shared.component.IdentifiedComponent;
+import net.shibboleth.shared.resolver.Resolver;
+
+/**
+ * Resolver of the {@link CredentialIssuerMetadata} this deployment publishes.
+ */
+public interface CredentialIssuerMetadataResolver
+ extends Resolver<CredentialIssuerMetadata, ProfileRequestContext>, IdentifiedComponent {
+
+}
diff --git a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/resolver/CredentialIssuerMetadataValueResolver.java b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/resolver/CredentialIssuerMetadataValueResolver.java
new file mode 100644
index 0000000..03c08f0
--- /dev/null
+++ b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/resolver/CredentialIssuerMetadataValueResolver.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * 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 org.geant.shibboleth.plugin.openidvci.metadata.resolver;
+
+import org.opensaml.profile.context.ProfileRequestContext;
+
+import net.shibboleth.shared.component.IdentifiedComponent;
+import net.shibboleth.shared.resolver.Resolver;
+
+/**
+ * Resolver of the value of Credential Issuer metadata.
+ */
+public interface CredentialIssuerMetadataValueResolver
+ extends Resolver<Object, ProfileRequestContext>, IdentifiedComponent {
+
+}
diff --git a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/resolver/RefreshableCredentialIssuerMetadataValueResolver.java b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/resolver/RefreshableCredentialIssuerMetadataValueResolver.java
new file mode 100644
index 0000000..c50cb6c
--- /dev/null
+++ b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/resolver/RefreshableCredentialIssuerMetadataValueResolver.java
@@ -0,0 +1,57 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * 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 org.geant.shibboleth.plugin.openidvci.metadata.resolver;
+
+import java.time.Instant;
+
+import javax.annotation.Nullable;
+
+import net.shibboleth.shared.resolver.ResolverException;
+
+/**
+ * Specialization of {@link CredentialIssuerMetadataValueResolver} that supports
+ * on-demand refresh.
+ */
+public interface RefreshableCredentialIssuerMetadataValueResolver extends CredentialIssuerMetadataValueResolver {
+
+ /**
+ * Refresh the data exposed by the resolver.
+ *
+ * @throws ResolverException if the refresh operation was unsuccessful
+ */
+ void refresh() throws ResolverException;
+
+ /**
+ * Get the time the last refresh cycle occurred.
+ *
+ * @return time the last refresh cycle occurred
+ */
+ @Nullable
+ Instant getLastRefresh();
+
+ /**
+ * Get the time the currently available value was last updated. This may be
+ * earlier than {@link #getLastRefresh()}, a refresh cycle that found nothing
+ * changed does not update it.
+ *
+ * @return time the currently available value was last updated, or null if it
+ * has never been read in successfully
+ */
+ @Nullable
+ Instant getLastUpdate();
+
+}
diff --git a/openid-vci-api/src/test/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialConfigurationsTest.java b/openid-vci-api/src/test/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialConfigurationsTest.java
index 484b893..282e17e 100644
--- a/openid-vci-api/src/test/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialConfigurationsTest.java
+++ b/openid-vci-api/src/test/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialConfigurationsTest.java
@@ -17,6 +17,8 @@
package org.geant.shibboleth.plugin.openidvci.credential;
import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.Map;
import org.springframework.core.io.ClassPathResource;
import org.testng.Assert;
@@ -33,13 +35,42 @@ public class CredentialConfigurationsTest {
public void parse() throws IOException {
CredentialConfigurations configurations = CredentialConfigurations
- .parse(ResourceHelper.of(new ClassPathResource("credentials/CredentialConfigurationsSupported.json")));
+ .parse(ResourceHelper.of(new ClassPathResource("credentials/CredentialConfigurationsSupported.json"))
+ .getInputStream().readAllBytes());
CredentialConfiguration configuration = configurations.getCredentialConfigurations().get("GeantIncubatorSdJwt");
Assert.assertEquals(configuration.getFormat(), "dc+sd-jwt");
- Assert.assertEquals(configuration.getScope(),"SD_JWT_VC_example_in_OpenID4VCI");
- Assert.assertEquals(configuration.getCredentialSigningAlgValuesSupported().get(0),"ES256");
+ Assert.assertEquals(configuration.getScope(), "SD_JWT_VC_example_in_OpenID4VCI");
+ Assert.assertEquals(configuration.getCredentialSigningAlgValuesSupported().get(0), "ES256");
Assert.assertEquals(configuration.getCryptographicBindingMethodsSupported().get(0), "jwk");
+
+ // Same source, kept as written for publishing.
+ Assert.assertEquals(configurations.getSupported().keySet(),
+ configurations.getCredentialConfigurations().keySet());
+ @SuppressWarnings("unchecked")
+ final Map<String, Object> source = (Map<String, Object>) configurations.getSupported()
+ .get("GeantIncubatorSdJwt");
+ Assert.assertEquals(source.get("format"), "dc+sd-jwt");
+ }
+
+ @Test
+ public void parseNoCredentialConfigurations() {
+ Assert.assertThrows(IOException.class,
+ () -> CredentialConfigurations.parse("{\"something_else\":{}}".getBytes(StandardCharsets.UTF_8)));
+ }
+
+ @Test
+ public void parseEmptyCredentialConfigurations() {
+ Assert.assertThrows(IOException.class, () -> CredentialConfigurations
+ .parse("{\"credential_configurations_supported\":{}}".getBytes(StandardCharsets.UTF_8)));
+ }
+
+ @Test
+ public void parseUnparsableCredentialConfiguration() {
+ Assert.assertThrows(IOException.class,
+ () -> CredentialConfigurations.parse(("{\"credential_configurations_supported\":"
+ + "{\"Broken\":{\"format\":\"dc+sd-jwt\",\"proof_types_supported\":\"not an object\"}}}")
+ .getBytes(StandardCharsets.UTF_8)));
}
}
\ No newline at end of file
diff --git a/openid-vci-api/src/test/java/org/geant/shibboleth/plugin/openidvci/metadata/CredentialIssuerMetadataTest.java b/openid-vci-api/src/test/java/org/geant/shibboleth/plugin/openidvci/metadata/CredentialIssuerMetadataTest.java
new file mode 100644
index 0000000..2d5985a
--- /dev/null
+++ b/openid-vci-api/src/test/java/org/geant/shibboleth/plugin/openidvci/metadata/CredentialIssuerMetadataTest.java
@@ -0,0 +1,115 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * 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 org.geant.shibboleth.plugin.openidvci.metadata;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.testng.Assert;
+import org.testng.annotations.Test;
+
+import com.nimbusds.oauth2.sdk.ParseException;
+
+/**
+ * Unit tests for {@link CredentialIssuerMetadata}.
+ */
+public class CredentialIssuerMetadataTest {
+
+ private Map<String, Object> members() {
+ final Map<String, Object> members = new LinkedHashMap<>();
+ members.put(CredentialIssuerMetadata.CREDENTIAL_ISSUER, "https://example.org");
+ members.put(CredentialIssuerMetadata.CREDENTIAL_ENDPOINT, "https://example.org/credentials");
+ members.put("display", List.of(Map.of("name", "Example")));
+ return members;
+ }
+
+ @Test
+ public void testSuccess() throws ParseException {
+ final CredentialIssuerMetadata metadata = CredentialIssuerMetadata.parse(members());
+
+ Assert.assertEquals(metadata.getCredentialIssuer().getValue(), "https://example.org");
+ Assert.assertTrue(metadata.hasMember(CredentialIssuerMetadata.CREDENTIAL_ENDPOINT));
+ Assert.assertFalse(metadata.hasMember(CredentialIssuerMetadata.CREDENTIAL_CONFIGURATIONS_SUPPORTED));
+ }
+
+ @Test
+ public void testMemberOrderIsPreserved() throws ParseException {
+ final CredentialIssuerMetadata metadata = CredentialIssuerMetadata.parse(members());
+
+ Assert.assertEquals(new ArrayList<>(metadata.getMembers().keySet()), List.of(
+ CredentialIssuerMetadata.CREDENTIAL_ISSUER, CredentialIssuerMetadata.CREDENTIAL_ENDPOINT, "display"));
+ }
+
+ @Test
+ public void testMembersAreNotModifiable() throws ParseException {
+ final CredentialIssuerMetadata metadata = CredentialIssuerMetadata.parse(members());
+
+ Assert.assertThrows(UnsupportedOperationException.class, () -> metadata.getMembers().put("x", "y"));
+ }
+
+ @Test
+ public void testParsedDocumentIsDetachedFromInput() throws ParseException {
+ final Map<String, Object> input = members();
+ final CredentialIssuerMetadata metadata = CredentialIssuerMetadata.parse(input);
+
+ input.put("nonce_endpoint", "https://example.org/nonce");
+
+ Assert.assertFalse(metadata.hasMember("nonce_endpoint"));
+ }
+
+ @Test
+ public void testModifiableMembersAreACopy() throws ParseException {
+ final CredentialIssuerMetadata metadata = CredentialIssuerMetadata.parse(members());
+
+ final Map<String, Object> copy = metadata.toModifiableMembers();
+ copy.put(CredentialIssuerMetadata.CREDENTIAL_CONFIGURATIONS_SUPPORTED, Map.of());
+
+ Assert.assertFalse(metadata.hasMember(CredentialIssuerMetadata.CREDENTIAL_CONFIGURATIONS_SUPPORTED));
+ }
+
+ @Test
+ public void testNoMembers() {
+ Assert.assertThrows(ParseException.class, () -> CredentialIssuerMetadata.parse(null));
+ }
+
+ @Test
+ public void testNoCredentialIssuer() {
+ final Map<String, Object> members = members();
+ members.remove(CredentialIssuerMetadata.CREDENTIAL_ISSUER);
+
+ Assert.assertThrows(ParseException.class, () -> CredentialIssuerMetadata.parse(members));
+ }
+
+ @Test
+ public void testEmptyCredentialIssuer() {
+ final Map<String, Object> members = members();
+ members.put(CredentialIssuerMetadata.CREDENTIAL_ISSUER, "");
+
+ Assert.assertThrows(ParseException.class, () -> CredentialIssuerMetadata.parse(members));
+ }
+
+ @Test
+ public void testCredentialIssuerNotAString() {
+ final Map<String, Object> members = members();
+ members.put(CredentialIssuerMetadata.CREDENTIAL_ISSUER, List.of("https://example.org"));
+
+ Assert.assertThrows(ParseException.class, () -> CredentialIssuerMetadata.parse(members));
+ }
+
+}
diff --git a/openid-vci-impl/pom.xml b/openid-vci-impl/pom.xml
index f94b128..1835c69 100644
--- a/openid-vci-impl/pom.xml
+++ b/openid-vci-impl/pom.xml
@@ -36,11 +36,31 @@
<artifactId>idp-admin-api</artifactId>
<scope>provided</scope>
</dependency>
+ <dependency>
+ <groupId>${shib-shared.groupId}</groupId>
+ <artifactId>shib-velocity</artifactId>
+ <scope>provided</scope>
+ </dependency>
+ <!--
+ Interface of the OpenID Federation entity configuration decorator. Compile only,
+ the decorator bean is created only when a deployer imports openid-vci-oidfed.xml.
+ -->
+ <dependency>
+ <groupId>${oidfed-common.groupId}</groupId>
+ <artifactId>oidfed-common-api</artifactId>
+ <version>${oidfed-common.version}</version>
+ <scope>provided</scope>
+ </dependency>
<dependency>
<groupId>${shib-shared.groupId}.oidc</groupId>
<artifactId>oidc-common-metadata-api</artifactId>
<scope>provided</scope>
</dependency>
+ <dependency>
+ <groupId>${shib-shared.groupId}.oidc</groupId>
+ <artifactId>oidc-common-metadata-impl</artifactId>
+ <scope>provided</scope>
+ </dependency>
<dependency>
<groupId>net.shibboleth.idp.plugin.oidc</groupId>
<artifactId>idp-plugin-oidc-op-api</artifactId>
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/config/impl/DefaultOpenIDVCIIssuerMetadataConfiguration.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/config/impl/DefaultOpenIDVCIIssuerMetadataConfiguration.java
new file mode 100644
index 0000000..e429639
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/config/impl/DefaultOpenIDVCIIssuerMetadataConfiguration.java
@@ -0,0 +1,49 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * 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 org.geant.shibboleth.plugin.openidvci.config.impl;
+
+import javax.annotation.Nonnull;
+
+import net.shibboleth.idp.profile.config.AbstractInterceptorAwareProfileConfiguration;
+import net.shibboleth.shared.annotation.constraint.NotEmpty;
+
+/**
+ * Profile configuration for the endpoint publishing Credential Issuer metadata,
+ * as in
+ * <a href="https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-issuer-metadata">
+ * OpenID4VCI</a>.
+ *
+ */
+public class DefaultOpenIDVCIIssuerMetadataConfiguration extends AbstractInterceptorAwareProfileConfiguration {
+
+ /** OpenID4VCI specification URI. */
+ @Nonnull
+ @NotEmpty
+ public static final String PROTOCOL_URI =
+ "https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html";
+
+ /** ID for Credential Issuer metadata profile configuration. */
+ @Nonnull
+ @NotEmpty
+ public static final String PROFILE_ID = "http://geant.org/ns/profiles/openid/vci/issuer-metadata";
+
+ /** Constructor. */
+ public DefaultOpenIDVCIIssuerMetadataConfiguration() {
+ super(PROFILE_ID);
+ }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/impl/FilesystemCredentialConfigurationsResolver.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/impl/FilesystemCredentialConfigurationsResolver.java
new file mode 100644
index 0000000..6607f97
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/impl/FilesystemCredentialConfigurationsResolver.java
@@ -0,0 +1,110 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * 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 org.geant.shibboleth.plugin.openidvci.credential.impl;
+
+import java.io.IOException;
+import java.util.Iterator;
+import java.util.List;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.geant.shibboleth.plugin.openidvci.credential.CredentialConfigurations;
+import org.geant.shibboleth.plugin.openidvci.credential.resolver.RefreshableCredentialConfigurationsResolver;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+import org.springframework.core.io.Resource;
+
+import com.nimbusds.oauth2.sdk.ParseException;
+import com.nimbusds.oauth2.sdk.id.Identifier;
+
+import net.shibboleth.oidc.metadata.impl.AbstractFileOIDCEntityResolver;
+import net.shibboleth.shared.collection.CollectionSupport;
+import net.shibboleth.shared.primitive.LoggerFactory;
+import net.shibboleth.shared.resolver.ResolverException;
+
+/**
+ * Credential Configurations read from a file, with the refresh behaviour of
+ * {@link AbstractFileOIDCEntityResolver}.
+ *
+ */
+public class FilesystemCredentialConfigurationsResolver
+ extends AbstractFileOIDCEntityResolver<Identifier, CredentialConfigurations>
+ implements RefreshableCredentialConfigurationsResolver {
+
+ /** Key of the single value in the backing store. */
+ @Nonnull
+ private static final Identifier KEY = new Identifier("credential configurations");
+
+ /** Class logger. */
+ @Nonnull
+ private final Logger log = LoggerFactory.getLogger(FilesystemCredentialConfigurationsResolver.class);
+
+ /**
+ * Constructor.
+ *
+ * @param credentials the Credential Configurations file
+ *
+ * @throws IOException if the file cannot be loaded
+ */
+ public FilesystemCredentialConfigurationsResolver(@Nonnull final Resource credentials) throws IOException {
+ super(credentials);
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ @Nonnull
+ public Iterable<CredentialConfigurations> resolve(@Nullable final ProfileRequestContext profileRequestContext)
+ throws ResolverException {
+ ifNotInitializedThrowUninitializedComponentException();
+ ifDestroyedThrowDestroyedComponentException();
+
+ return getBackingStore().getOrderedInformation();
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ @Nullable
+ public CredentialConfigurations resolveSingle(@Nullable final ProfileRequestContext profileRequestContext)
+ throws ResolverException {
+ final Iterator<CredentialConfigurations> iterator = resolve(profileRequestContext).iterator();
+ if (iterator.hasNext()) {
+ return iterator.next();
+ }
+ log.warn("{} No Credential Configurations have been read in", getLogPrefix());
+ return null;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ @Nonnull
+ protected List<CredentialConfigurations> parse(@Nonnull final byte[] bytes) throws ParseException {
+ try {
+ return CollectionSupport.listOf(CredentialConfigurations.parse(bytes));
+ } catch (final IOException e) {
+ throw new ParseException(e.getMessage(), e);
+ }
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ @Nonnull
+ protected Identifier getKey(@Nonnull final CredentialConfigurations value) {
+ return KEY;
+ }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/impl/CredentialIssuerMetadataSuccessResponse.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/impl/CredentialIssuerMetadataSuccessResponse.java
new file mode 100644
index 0000000..733320a
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/impl/CredentialIssuerMetadataSuccessResponse.java
@@ -0,0 +1,67 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * 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 org.geant.shibboleth.plugin.openidvci.messaging.impl;
+
+import java.util.Map;
+
+import javax.annotation.Nonnull;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.nimbusds.common.contenttype.ContentType;
+import com.nimbusds.oauth2.sdk.SuccessResponse;
+import com.nimbusds.oauth2.sdk.http.HTTPResponse;
+
+/**
+ * Response carrying the Credential Issuer metadata, as in <a href=
+ * "https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-issuer-metadata">
+ * OpenID4VCI</a>.
+ */
+public class CredentialIssuerMetadataSuccessResponse implements SuccessResponse {
+
+ /** Serialized metadata document. */
+ @Nonnull
+ private final String content;
+
+ /**
+ * Constructor.
+ *
+ * @param metadata members of the metadata document
+ *
+ * @throws JsonProcessingException if the members cannot be serialized
+ */
+ public CredentialIssuerMetadataSuccessResponse(@Nonnull final Map<String, Object> metadata)
+ throws JsonProcessingException {
+ content = new ObjectMapper().writeValueAsString(metadata);
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public boolean indicatesSuccess() {
+ return true;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public HTTPResponse toHTTPResponse() {
+ final HTTPResponse httpResponse = new HTTPResponse(HTTPResponse.SC_OK);
+ httpResponse.setEntityContentType(ContentType.APPLICATION_JSON);
+ httpResponse.setContent(content);
+ return httpResponse;
+ }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/CredentialConfigurationsValueResolver.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/CredentialConfigurationsValueResolver.java
new file mode 100644
index 0000000..75bca86
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/CredentialConfigurationsValueResolver.java
@@ -0,0 +1,120 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * 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 org.geant.shibboleth.plugin.openidvci.metadata.impl;
+
+import java.time.Instant;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.geant.shibboleth.plugin.openidvci.credential.CredentialConfigurations;
+import org.geant.shibboleth.plugin.openidvci.credential.resolver.CredentialConfigurationsResolver;
+import org.geant.shibboleth.plugin.openidvci.credential.resolver.RefreshableCredentialConfigurationsResolver;
+import org.geant.shibboleth.plugin.openidvci.metadata.resolver.RefreshableCredentialIssuerMetadataValueResolver;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+
+import net.shibboleth.shared.collection.CollectionSupport;
+import net.shibboleth.shared.component.AbstractIdentifiableInitializableComponent;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.primitive.LoggerFactory;
+import net.shibboleth.shared.resolver.ResolverException;
+
+/**
+ * Resolver serving the 'credential_configurations_supported' member of the
+ * published Credential Issuer metadata from the
+ * {@link CredentialConfigurationsResolver} the issuance flows read.
+ *
+ * The member is published as the deployer wrote it.
+ */
+public class CredentialConfigurationsValueResolver extends AbstractIdentifiableInitializableComponent
+ implements RefreshableCredentialIssuerMetadataValueResolver {
+
+ /** Class logger. */
+ @Nonnull
+ private final Logger log = LoggerFactory.getLogger(CredentialConfigurationsValueResolver.class);
+
+ /** Resolver of the Credential Configurations to publish. */
+ @Nonnull
+ private final CredentialConfigurationsResolver credentialConfigurationsResolver;
+
+ /**
+ * Constructor.
+ *
+ * @param credentialConfigurations resolver of the Credential Configurations to
+ * publish
+ */
+ public CredentialConfigurationsValueResolver(
+ @Nonnull final CredentialConfigurationsResolver credentialConfigurations) {
+ credentialConfigurationsResolver = Constraint.isNotNull(credentialConfigurations,
+ "Credential Configurations resolver cannot be null");
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ @Nonnull
+ public Iterable<Object> resolve(@Nullable final ProfileRequestContext profileRequestContext)
+ throws ResolverException {
+ final Object configurations = resolveSingle(profileRequestContext);
+ return configurations == null ? CollectionSupport.emptyList() : CollectionSupport.listOf(configurations);
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ @Nullable
+ public Object resolveSingle(@Nullable final ProfileRequestContext profileRequestContext)
+ throws ResolverException {
+ checkComponentActive();
+
+ final CredentialConfigurations configurations =
+ credentialConfigurationsResolver.resolveSingle(profileRequestContext);
+ if (configurations == null) {
+ log.warn("{} No Credential Configurations to publish", getId());
+ return null;
+ }
+ return configurations.getSupported();
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public void refresh() throws ResolverException {
+ if (credentialConfigurationsResolver instanceof RefreshableCredentialConfigurationsResolver refreshable) {
+ refreshable.refresh();
+ }
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ @Nullable
+ public Instant getLastRefresh() {
+ if (credentialConfigurationsResolver instanceof RefreshableCredentialConfigurationsResolver refreshable) {
+ return refreshable.getLastRefresh();
+ }
+ return null;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ @Nullable
+ public Instant getLastUpdate() {
+ if (credentialConfigurationsResolver instanceof RefreshableCredentialConfigurationsResolver refreshable) {
+ return refreshable.getLastUpdate();
+ }
+ return null;
+ }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/DynamicFilesystemCredentialIssuerMetadataResolver.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/DynamicFilesystemCredentialIssuerMetadataResolver.java
new file mode 100644
index 0000000..2c2a255
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/DynamicFilesystemCredentialIssuerMetadataResolver.java
@@ -0,0 +1,131 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * 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 org.geant.shibboleth.plugin.openidvci.metadata.impl;
+
+import java.io.IOException;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.geant.shibboleth.plugin.openidvci.metadata.CredentialIssuerMetadata;
+import org.geant.shibboleth.plugin.openidvci.metadata.resolver.CredentialIssuerMetadataValueResolver;
+import org.geant.shibboleth.plugin.openidvci.metadata.resolver.RefreshableCredentialIssuerMetadataValueResolver;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+import org.springframework.core.io.Resource;
+
+import com.nimbusds.oauth2.sdk.ParseException;
+
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.primitive.LoggerFactory;
+import net.shibboleth.shared.resolver.ResolverException;
+
+/**
+ * Extension to {@link FilesystemCredentialIssuerMetadataResolver} that lets
+ * some members of the published document be resolved outside the file.
+ */
+public class DynamicFilesystemCredentialIssuerMetadataResolver extends FilesystemCredentialIssuerMetadataResolver {
+
+ /** Class logger. */
+ @Nonnull
+ private final Logger log = LoggerFactory.getLogger(DynamicFilesystemCredentialIssuerMetadataResolver.class);
+
+ /** Resolvers of members resolved outside the file, keyed by member name. */
+ @Nonnull
+ private Map<String, ? extends CredentialIssuerMetadataValueResolver> dynamicResolvers = new HashMap<>();
+
+ /**
+ * Constructor.
+ *
+ * @param metadata the metadata file
+ *
+ * @throws IOException if the metadata cannot be loaded
+ */
+ public DynamicFilesystemCredentialIssuerMetadataResolver(@Nonnull final Resource metadata) throws IOException {
+ super(metadata);
+ }
+
+ /**
+ * Set the resolvers of members resolved outside the file.
+ *
+ * @param map resolvers keyed by the member each one resolves
+ */
+ public void setDynamicValueResolvers(
+ @Nonnull final Map<String, ? extends CredentialIssuerMetadataValueResolver> map) {
+ dynamicResolvers = Constraint.isNotNull(map, "The map of dynamic metadata resolvers cannot be null");
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ @Nonnull
+ public Iterable<CredentialIssuerMetadata> resolve(@Nullable final ProfileRequestContext profileRequestContext)
+ throws ResolverException {
+
+ final List<CredentialIssuerMetadata> result = new ArrayList<>();
+ for (final CredentialIssuerMetadata entity : super.resolve(profileRequestContext)) {
+ final Map<String, Object> members = entity.toModifiableMembers();
+
+ for (final String member : dynamicResolvers.keySet()) {
+ log.debug("{} Starting to resolve value for {}", getLogPrefix(), member);
+ try {
+ final CredentialIssuerMetadataValueResolver resolver = dynamicResolvers.get(member);
+ if (resolver instanceof RefreshableCredentialIssuerMetadataValueResolver refreshable) {
+ refreshable.refresh();
+ }
+ final Object value = resolver.resolveSingle(profileRequestContext);
+ if (value != null) {
+ members.put(member, value);
+ log.debug("{} Member {} updated to the result", getLogPrefix(), member);
+ }
+ } catch (final ResolverException e) {
+ log.warn("{} Could not resolve a value for {}, ignoring it", getLogPrefix(), member, e);
+ }
+ }
+
+ try {
+ result.add(CredentialIssuerMetadata.parse(members));
+ } catch (final ParseException e) {
+ log.warn("{} The resulting Credential Issuer metadata is not valid, ignoring it", getLogPrefix(), e);
+ }
+ }
+ return result;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ protected Instant getMetadataUpdateTime() {
+ Instant updateTime = super.getMetadataUpdateTime();
+ for (final CredentialIssuerMetadataValueResolver resolver : dynamicResolvers.values()) {
+ if (resolver instanceof RefreshableCredentialIssuerMetadataValueResolver refreshable) {
+ final Instant lastUpdate = refreshable.getLastUpdate();
+ if (lastUpdate == null) {
+ return Instant.now();
+ }
+ if (lastUpdate.isAfter(updateTime)) {
+ updateTime = lastUpdate;
+ }
+ }
+ }
+ return updateTime;
+ }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/FilesystemCredentialIssuerMetadataResolver.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/FilesystemCredentialIssuerMetadataResolver.java
new file mode 100644
index 0000000..0fa2b57
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/FilesystemCredentialIssuerMetadataResolver.java
@@ -0,0 +1,300 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * 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 org.geant.shibboleth.plugin.openidvci.metadata.impl;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.nio.charset.StandardCharsets;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Pattern;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.apache.velocity.VelocityContext;
+import org.apache.velocity.app.VelocityEngine;
+import org.apache.velocity.exception.VelocityException;
+import org.apache.velocity.runtime.resource.loader.StringResourceLoader;
+import org.geant.shibboleth.plugin.openidvci.metadata.CredentialIssuerMetadata;
+import org.geant.shibboleth.plugin.openidvci.metadata.resolver.CredentialIssuerMetadataResolver;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+import org.springframework.core.io.Resource;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.nimbusds.oauth2.sdk.ParseException;
+import com.nimbusds.oauth2.sdk.id.Issuer;
+
+import net.shibboleth.oidc.metadata.impl.AbstractFileOIDCEntityResolver;
+import net.shibboleth.shared.collection.CollectionSupport;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.primitive.LoggerFactory;
+import net.shibboleth.shared.resolver.ResolverException;
+import net.shibboleth.shared.velocity.Template;
+
+/**
+ * Credential Issuer metadata read from a file, with the refresh behaviour of
+ * {@link AbstractFileOIDCEntityResolver}.
+ */
+public class FilesystemCredentialIssuerMetadataResolver extends
+ AbstractFileOIDCEntityResolver<Issuer, CredentialIssuerMetadata> implements CredentialIssuerMetadataResolver {
+
+ /** Class logger. */
+ @Nonnull
+ private final Logger log = LoggerFactory.getLogger(FilesystemCredentialIssuerMetadataResolver.class);
+
+ /** Variable carrying the issuer as configured. */
+ @Nonnull
+ private static final String ISSUER_VARIABLE = "issuer";
+
+ /** Variable carrying scheme and authority of the issuer. */
+ @Nonnull
+ private static final String BASE_URL_VARIABLE = "baseUrl";
+
+ /** Variable carrying the host of the issuer. */
+ @Nonnull
+ private static final String HOST_VARIABLE = "host";
+
+ /** Reference Velocity leaves in place when it resolves to nothing. */
+ @Nonnull
+ private static final Pattern UNRESOLVED = Pattern.compile("\\$\\{?[A-Za-z_]");
+
+ /** Parser of the metadata file. */
+ @Nonnull
+ private final ObjectMapper objectMapper = new ObjectMapper();
+
+ /** Engine evaluating the metadata file, or null to read the file as it is. */
+ @Nullable
+ private VelocityEngine velocityEngine;
+
+ /** Issuer the variables of the template are derived from. */
+ @Nullable
+ private String issuer;
+
+ /** Variables the deployer adds to the template. */
+ @Nullable
+ private Map<String, ?> templateContext;
+
+ /** Variables offered to the template. */
+ @Nonnull
+ private Map<String, Object> variables = Map.of();
+
+ /**
+ * Constructor.
+ *
+ * @param metadata the metadata file
+ *
+ * @throws IOException if the metadata cannot be loaded
+ */
+ public FilesystemCredentialIssuerMetadataResolver(@Nonnull final Resource metadata) throws IOException {
+ super(metadata);
+ }
+
+ /**
+ * Set the engine evaluating the metadata file.
+ *
+ * Leaving this unset reads the file as it is, for a document that carries '$'
+ * or '#' as content.
+ *
+ * @param engine engine to use, or null to read the file as it is
+ */
+ public void setVelocityEngine(@Nullable final VelocityEngine engine) {
+ checkSetterPreconditions();
+ velocityEngine = engine;
+ }
+
+ /**
+ * Set the issuer the variables of the template are derived from.
+ *
+ * This is the deployment's issuer, so '$baseUrl' names this host even when the
+ * issuer itself carries a path.
+ *
+ * @param identifier issuer to derive the variables from
+ */
+ public void setIssuer(@Nullable final String identifier) {
+ checkSetterPreconditions();
+ issuer = identifier;
+ }
+
+ /**
+ * Set the variables the deployer adds to the template.
+ *
+ * These are applied after the derived ones, so a deployer may also replace
+ * those.
+ *
+ * @param context variables to add
+ */
+ public void setTemplateContext(@Nullable final Map<String, ?> context) {
+ checkSetterPreconditions();
+ templateContext = context;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ protected void doInitialize() throws ComponentInitializationException {
+ resolveVariables();
+ super.doInitialize();
+ }
+
+ /**
+ * Resolve the variables offered to the template.
+ *
+ * @throws ComponentInitializationException if the issuer does not name a host
+ */
+ private void resolveVariables() throws ComponentInitializationException {
+
+ if (velocityEngine == null) {
+ return;
+ }
+
+ final Map<String, Object> resolved = new LinkedHashMap<>();
+ if (issuer != null && !issuer.isEmpty()) {
+ final URI uri;
+ try {
+ uri = new URI(issuer);
+ } catch (final URISyntaxException e) {
+ throw new ComponentInitializationException("Issuer '" + issuer + "' is not a uri", e);
+ }
+ if (uri.getScheme() == null || uri.getRawAuthority() == null) {
+ throw new ComponentInitializationException("Issuer '" + issuer + "' names no host");
+ }
+ resolved.put(ISSUER_VARIABLE, issuer);
+ resolved.put(BASE_URL_VARIABLE, uri.getScheme() + "://" + uri.getRawAuthority());
+ resolved.put(HOST_VARIABLE, uri.getHost());
+ }
+ if (templateContext != null) {
+ resolved.putAll(templateContext);
+ }
+ variables = resolved;
+
+ log.debug("Credential Issuer metadata template variables {}", resolved);
+ }
+
+ /**
+ * Evaluate the metadata template.
+ *
+ * @param template template to evaluate
+ * @return the evaluated document
+ * @throws ParseException if the template cannot be evaluated
+ */
+ @Nonnull
+ private String evaluate(@Nonnull final String template) throws ParseException {
+
+ assert velocityEngine != null;
+
+ if (template.isBlank()) {
+ throw new ParseException("Credential Issuer metadata template is empty");
+ }
+
+ final String document;
+ Template compiled = null;
+ try {
+ compiled = Template.fromTemplate(velocityEngine, template, StandardCharsets.UTF_8);
+ final VelocityContext context = new VelocityContext();
+ variables.forEach(context::put);
+ document = compiled.merge(context);
+ } catch (final VelocityException e) {
+ throw new ParseException("Unable to evaluate Credential Issuer metadata template: " + e.getMessage(), e);
+ } finally {
+ // fromTemplate registers the template in a repository that outlives it.
+ if (compiled != null) {
+ StringResourceLoader.getRepository().removeStringResource(compiled.getTemplateName());
+ }
+ }
+
+ if (UNRESOLVED.matcher(document).find()) {
+ log.warn(
+ "{} Credential Issuer metadata still reads as a template after evaluating it. A reference to "
+ + "something other than {} resolves to nothing and is left in place.",
+ getLogPrefix(), variables.keySet());
+ }
+
+ return document;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ @Nonnull
+ public Iterable<CredentialIssuerMetadata> resolve(@Nullable final ProfileRequestContext profileRequestContext)
+ throws ResolverException {
+ ifNotInitializedThrowUninitializedComponentException();
+ ifDestroyedThrowDestroyedComponentException();
+
+ return getBackingStore().getOrderedInformation();
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ @Nullable
+ public CredentialIssuerMetadata resolveSingle(@Nullable final ProfileRequestContext profileRequestContext)
+ throws ResolverException {
+ final Iterator<CredentialIssuerMetadata> iterator = resolve(profileRequestContext).iterator();
+ if (iterator.hasNext()) {
+ return iterator.next();
+ }
+ log.warn("{} No Credential Issuer metadata has been read in", getLogPrefix());
+ return null;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ @Nonnull
+ protected List<CredentialIssuerMetadata> parse(@Nonnull final byte[] bytes) throws ParseException {
+ return CollectionSupport.listOf(CredentialIssuerMetadata.parse(readMembers(bytes)));
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ @Nonnull
+ protected Issuer getKey(@Nonnull final CredentialIssuerMetadata value) {
+ return value.getCredentialIssuer();
+ }
+
+ /**
+ * Read the members of a metadata document.
+ *
+ * @param bytes document to read
+ * @return members of the document, in the order they appear in it
+ * @throws ParseException if the bytes are not a JSON object
+ */
+ @Nonnull
+ protected Map<String, Object> readMembers(@Nonnull final byte[] bytes) throws ParseException {
+
+ String document = new String(bytes, StandardCharsets.UTF_8);
+ if (velocityEngine != null) {
+ document = evaluate(document);
+ }
+
+ try {
+ final Map<String, Object> members = objectMapper.readValue(document,
+ new TypeReference<LinkedHashMap<String, Object>>() {
+ });
+ if (members == null) {
+ throw new ParseException("Credential Issuer metadata is not a JSON object");
+ }
+ return members;
+ } catch (final IOException e) {
+ throw new ParseException("Unable to parse Credential Issuer metadata: " + e.getMessage(), e);
+ }
+ }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/oidfed/impl/CredentialIssuerEntityConfigurationMetadataDecorator.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/oidfed/impl/CredentialIssuerEntityConfigurationMetadataDecorator.java
new file mode 100644
index 0000000..b4fb3ff
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/oidfed/impl/CredentialIssuerEntityConfigurationMetadataDecorator.java
@@ -0,0 +1,306 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * 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 org.geant.shibboleth.plugin.openidvci.oidfed.impl;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.geant.shibboleth.plugin.openidvci.metadata.CredentialIssuerMetadata;
+import org.geant.shibboleth.plugin.openidvci.metadata.resolver.CredentialIssuerMetadataResolver;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.opensaml.security.credential.Credential;
+import org.slf4j.Logger;
+
+import com.nimbusds.jose.jwk.JWK;
+
+import net.shibboleth.oidc.security.CredentialConversionUtil;
+import net.shibboleth.oidc.security.credential.JWKCredential;
+import net.shibboleth.oidfed.profile.EntityConfigurationMetadataDecorator;
+import net.shibboleth.shared.collection.CollectionSupport;
+import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
+import net.shibboleth.shared.component.AbstractIdentifiableInitializableComponent;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.logic.ConstraintViolationException;
+import net.shibboleth.shared.primitive.LoggerFactory;
+import net.shibboleth.shared.resolver.ResolverException;
+
+/**
+ * An {@link EntityConfigurationMetadataDecorator} that states this deployment
+ * is a Credential Issuer.
+ *
+ */
+public class CredentialIssuerEntityConfigurationMetadataDecorator extends AbstractIdentifiableInitializableComponent
+ implements EntityConfigurationMetadataDecorator {
+
+ /** Entity type carrying the Credential Issuer metadata. */
+ @Nonnull
+ public static final String OPENID_CREDENTIAL_ISSUER = "openid_credential_issuer";
+
+ /** Entity type carrying the keys Credentials are signed with. */
+ @Nonnull
+ public static final String VC_ISSUER = "vc_issuer";
+
+ /** Entity type carrying the display properties of the entity. */
+ @Nonnull
+ public static final String FEDERATION_ENTITY = "federation_entity";
+
+ /** Member carrying a JWK Set. */
+ @Nonnull
+ public static final String JWKS = "jwks";
+
+ /** Member of a JWK Set carrying the keys. */
+ @Nonnull
+ public static final String KEYS = "keys";
+
+ /** Member carrying the algorithm of a key. */
+ @Nonnull
+ public static final String ALG = "alg";
+
+ /** Member of Credential Issuer metadata carrying display properties. */
+ @Nonnull
+ public static final String DISPLAY = "display";
+
+ /** Member of display properties carrying a name. */
+ @Nonnull
+ public static final String NAME = "name";
+
+ /** Member of display properties carrying a logo. */
+ @Nonnull
+ public static final String LOGO = "logo";
+
+ /** Member of a logo carrying its location. */
+ @Nonnull
+ public static final String URI = "uri";
+
+ /** Member of federation_entity carrying a display name. */
+ @Nonnull
+ public static final String DISPLAY_NAME = "display_name";
+
+ /** Member of federation_entity carrying a logo location. */
+ @Nonnull
+ public static final String LOGO_URI = "logo_uri";
+
+ /** Class logger. */
+ @Nonnull
+ private final Logger log = LoggerFactory.getLogger(CredentialIssuerEntityConfigurationMetadataDecorator.class);
+
+ /** Resolver of the Credential Issuer metadata to state. */
+ @NonnullAfterInit
+ private CredentialIssuerMetadataResolver metadataResolver;
+
+ /** Keys Credentials are signed with. */
+ @NonnullAfterInit
+ private List<Credential> credentialSigningCredentials;
+
+ /**
+ * Whether display properties of the Credential Issuer fill federation_entity.
+ */
+ private boolean decorateFederationEntity = true;
+
+ /**
+ * Set the resolver of the Credential Issuer metadata to state.
+ *
+ * @param resolver resolver to use
+ */
+ public void setMetadataResolver(@Nonnull final CredentialIssuerMetadataResolver resolver) {
+ checkSetterPreconditions();
+ metadataResolver = Constraint.isNotNull(resolver, "The metadata resolver cannot be null");
+ }
+
+ /**
+ * Set the keys Credentials are signed with.
+ *
+ * These must be the keys Credentials are actually signed with, one stated here
+ * and not used leaves a verifier unable to read a Credential. So this takes the
+ * very list the Credential signing configuration takes, rather than a list of
+ * its own that could be edited to say something else.
+ *
+ * @param credentials keys to state, may be null when none are declared
+ */
+ public void setCredentialSigningCredentials(@Nullable final List<Credential> credentials) {
+ checkSetterPreconditions();
+ credentialSigningCredentials = credentials == null ? CollectionSupport.emptyList()
+ : CollectionSupport.copyToList(credentials);
+ }
+
+ /**
+ * Set whether display properties of the Credential Issuer fill
+ * federation_entity.
+ *
+ * Turn this off to state federation_entity entirely in the entity configuration
+ * metadata of the federation module.
+ *
+ * @param flag whether to fill federation_entity
+ */
+ public void setDecorateFederationEntity(final boolean flag) {
+ checkSetterPreconditions();
+ decorateFederationEntity = flag;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ protected void doInitialize() throws ComponentInitializationException {
+ super.doInitialize();
+
+ if (metadataResolver == null) {
+ throw new ComponentInitializationException("The metadata resolver cannot be null");
+ }
+ if (credentialSigningCredentials == null || credentialSigningCredentials.isEmpty()) {
+ throw new ComponentInitializationException("No keys resolvedt");
+ }
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public void accept(@Nullable final Map<String, Map<String, Object>> metadata,
+ @Nullable final ProfileRequestContext profileRequestContext) {
+ checkComponentActive();
+
+ if (metadata == null || profileRequestContext == null) {
+ log.error("Metadata {} nor profile request context {} cannot be null", metadata, profileRequestContext);
+ throw new ConstraintViolationException("Metadata nor profile request context cannot be null");
+ }
+
+ final CredentialIssuerMetadata credentialIssuer;
+ try {
+ credentialIssuer = metadataResolver.resolveSingle(profileRequestContext);
+ } catch (final ResolverException e) {
+ log.error("Could not resolve Credential Issuer metadata, entity configuration is left without it", e);
+ return;
+ }
+ if (credentialIssuer == null) {
+ log.error("Could not resolve Credential Issuer metadata, entity configuration is left without it");
+ return;
+ }
+
+ decorate(metadata, OPENID_CREDENTIAL_ISSUER, credentialIssuer.getMembers());
+
+ final List<Object> keys = credentialSigningKeys();
+ if (!keys.isEmpty()) {
+ decorate(metadata, VC_ISSUER, Map.of(JWKS, Map.of(KEYS, keys)));
+ } else {
+ log.error("None of the keys Credentials are signed with could be stated, entity configuration "
+ + "is left without {}", VC_ISSUER);
+ }
+
+ if (decorateFederationEntity) {
+ fill(metadata, FEDERATION_ENTITY, displayProperties(credentialIssuer));
+ }
+ }
+
+ /**
+ * Get the public half of the keys Credentials are signed with, as a JWK Set
+ * document.
+ *
+ * @return the keys of the JWK Set
+ */
+ @Nonnull
+ private List<Object> credentialSigningKeys() {
+
+ final List<Object> keys = new ArrayList<>();
+ for (final Credential credential : credentialSigningCredentials) {
+ // Conversion keeps the public components only.
+ final JWK key = CredentialConversionUtil.credentialToKey(credential);
+ if (key == null) {
+ log.warn("Credential signing key is neither RSA nor EC, leaving it out of {}", VC_ISSUER);
+ continue;
+ }
+ final Map<String, Object> published = new LinkedHashMap<>(key.toJSONObject());
+ if (credential instanceof JWKCredential jwkCredential && jwkCredential.getAlgorithm() != null) {
+ published.put(ALG, jwkCredential.getAlgorithm().getName());
+ }
+ keys.add(published);
+ }
+
+ return keys;
+ }
+
+ /**
+ * Get the display properties of federation_entity, read from the display
+ * properties of the Credential Issuer metadata.
+ *
+ * @param credentialIssuer metadata to read
+ * @return the display properties, empty when the metadata declares none
+ */
+ @Nonnull
+ private Map<String, Object> displayProperties(@Nonnull final CredentialIssuerMetadata credentialIssuer) {
+
+ final Object display = credentialIssuer.getMembers().get(DISPLAY);
+ if (!(display instanceof List) || ((List<?>) display).isEmpty()) {
+ log.debug("Credential Issuer metadata declares no display properties for {}", FEDERATION_ENTITY);
+ return Map.of();
+ }
+
+ // First entry, it is the one without a locale by convention.
+ final Object first = ((List<?>) display).get(0);
+ if (!(first instanceof Map)) {
+ log.warn("Display properties of Credential Issuer metadata are not an object, leaving {} alone",
+ FEDERATION_ENTITY);
+ return Map.of();
+ }
+ final Map<?, ?> properties = (Map<?, ?>) first;
+
+ final Map<String, Object> filled = new LinkedHashMap<>();
+ if (properties.get(NAME) != null) {
+ filled.put(DISPLAY_NAME, properties.get(NAME));
+ }
+ if (properties.get(LOGO) instanceof Map && ((Map<?, ?>) properties.get(LOGO)).get(URI) != null) {
+ filled.put(LOGO_URI, ((Map<?, ?>) properties.get(LOGO)).get(URI));
+ }
+ return filled;
+ }
+
+ /**
+ * Write members of an entity type, replacing the ones already there.
+ *
+ * @param metadata entity configuration metadata to write to
+ * @param entityType entity type to write
+ * @param members members to write
+ */
+ private void decorate(@Nonnull final Map<String, Map<String, Object>> metadata, @Nonnull final String entityType,
+ @Nonnull final Map<String, Object> members) {
+
+ if (metadata.containsKey(entityType)) {
+ metadata.get(entityType).putAll(members);
+ } else {
+ metadata.put(entityType, new LinkedHashMap<>(members));
+ }
+ log.debug("Stated entity type {} with members {}", entityType, members.keySet());
+ }
+
+ /**
+ * Write members of an entity type, keeping the ones already there.
+ *
+ * @param metadata entity configuration metadata to write to
+ * @param entityType entity type to write
+ * @param members members to write
+ */
+ private void fill(@Nonnull final Map<String, Map<String, Object>> metadata, @Nonnull final String entityType,
+ @Nonnull final Map<String, Object> members) {
+
+ final Map<String, Object> existing = metadata.computeIfAbsent(entityType, k -> new LinkedHashMap<>());
+ members.forEach(existing::putIfAbsent);
+ log.debug("Filled entity type {} with members {}", entityType, members.keySet());
+ }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/AbstractCredentialValidationAction.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/AbstractCredentialValidationAction.java
index e01b26e..689cf0b 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/AbstractCredentialValidationAction.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/AbstractCredentialValidationAction.java
@@ -16,29 +16,35 @@
package org.geant.shibboleth.plugin.openidvci.profile.impl;
-import java.io.IOException;
import java.util.Map;
import javax.annotation.Nonnull;
import org.geant.shibboleth.plugin.openidvci.credential.CredentialConfiguration;
import org.geant.shibboleth.plugin.openidvci.credential.CredentialConfigurations;
+import org.geant.shibboleth.plugin.openidvci.credential.resolver.CredentialConfigurationsResolver;
+import org.geant.shibboleth.plugin.openidvci.credential.resolver.RefreshableCredentialConfigurationsResolver;
import org.geant.shibboleth.plugin.openidvci.profile.OpenIDVCIEventIds;
import org.opensaml.profile.action.ActionSupport;
import org.opensaml.profile.context.ProfileRequestContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-import net.shibboleth.shared.resource.Resource;
+
import net.shibboleth.idp.profile.AbstractProfileAction;
import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
import net.shibboleth.shared.annotation.constraint.NonnullBeforeExec;
-import net.shibboleth.shared.annotation.constraint.NotEmpty;
import net.shibboleth.shared.component.ComponentInitializationException;
import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.resolver.ResolverException;
/**
* Abstract class for actions performing actions on
- * {@link CredentialConfigurations} source from resource.
+ * {@link CredentialConfigurations} resolved for the request.
+ *
+ * The configurations come from a resolver shared with every other flow reading
+ * them, including the one publishing Credential Issuer metadata. The resolver is
+ * refreshed here, so a change to the source still takes effect without a
+ * restart, but an unchanged source is not read or parsed again.
*/
abstract class AbstractCredentialValidationAction extends AbstractProfileAction {
@@ -46,32 +52,36 @@ abstract class AbstractCredentialValidationAction extends AbstractProfileAction
@Nonnull
private Logger log = LoggerFactory.getLogger(AbstractCredentialValidationAction.class);
- /** The supported credentials file. */
+ /** Resolver of the supported credentials. */
@NonnullAfterInit
- private Resource credentialsResource;
+ private CredentialConfigurationsResolver credentialConfigurationsResolver;
- /** The supported credentials parsed from file. */
+ /** The supported credentials resolved for this request. */
@NonnullBeforeExec
private CredentialConfigurations credentialConfigurations;
- /** The supported credentials file. */
- public void setCredentialsResource(@Nonnull @NotEmpty Resource resource) {
- assert resource != null;
- assert resource.exists();
- credentialsResource = resource;
+ /**
+ * Set the resolver of the supported credentials.
+ *
+ * @param resolver resolver to use
+ */
+ public void setCredentialConfigurationsResolver(@Nonnull final CredentialConfigurationsResolver resolver) {
+ checkSetterPreconditions();
+ credentialConfigurationsResolver = Constraint.isNotNull(resolver,
+ "Credential Configurations resolver cannot be null");
}
/** {@inheritDoc} */
@Override
protected void doInitialize() throws ComponentInitializationException {
super.doInitialize();
- Constraint.isNotNull(credentialsResource, "Credentials resource cannot be null");
+ Constraint.isNotNull(credentialConfigurationsResolver, "Credential Configurations resolver cannot be null");
}
/**
- * Get supported credentials parsed from file.
+ * Get supported credentials resolved for this request.
*
- * @return Supported credentials parsed from file
+ * @return Supported credentials resolved for this request
*/
public Map<String, CredentialConfiguration> getCredentialConfigurations() {
return credentialConfigurations.getCredentialConfigurations();
@@ -80,14 +90,23 @@ abstract class AbstractCredentialValidationAction extends AbstractProfileAction
/** {@inheritDoc} */
@Override
protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+
try {
- credentialConfigurations = CredentialConfigurations.parse(credentialsResource);
- } catch (IOException e) {
- log.error("{} Parsing credential configuration failed.", getLogPrefix(), e);
+ if (credentialConfigurationsResolver instanceof RefreshableCredentialConfigurationsResolver refreshable) {
+ refreshable.refresh();
+ }
+ credentialConfigurations = credentialConfigurationsResolver.resolveSingle(profileRequestContext);
+ } catch (final ResolverException e) {
+ log.error("{} Resolving credential configuration failed.", getLogPrefix(), e);
+ ActionSupport.buildEvent(profileRequestContext, OpenIDVCIEventIds.NO_CREDENTIAL_CONFIGURATION);
+ return false;
+ }
+ if (credentialConfigurations == null) {
+ log.error("{} No credential configuration resolved.", getLogPrefix());
ActionSupport.buildEvent(profileRequestContext, OpenIDVCIEventIds.NO_CREDENTIAL_CONFIGURATION);
return false;
}
return super.doPreExecute(profileRequestContext);
}
-}
\ No newline at end of file
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormOutboundIssuerMetadataResponseMessage.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormOutboundIssuerMetadataResponseMessage.java
new file mode 100644
index 0000000..9d2e788
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormOutboundIssuerMetadataResponseMessage.java
@@ -0,0 +1,140 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * 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 org.geant.shibboleth.plugin.openidvci.profile.impl;
+
+import javax.annotation.Nonnull;
+
+import org.geant.shibboleth.plugin.openidvci.messaging.impl.CredentialIssuerMetadataSuccessResponse;
+import org.geant.shibboleth.plugin.openidvci.metadata.CredentialIssuerMetadata;
+import org.geant.shibboleth.plugin.openidvci.metadata.resolver.CredentialIssuerMetadataResolver;
+import org.opensaml.profile.action.ActionSupport;
+import org.opensaml.profile.action.EventIds;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+
+import net.shibboleth.idp.profile.AbstractProfileAction;
+import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
+import net.shibboleth.shared.annotation.constraint.NonnullBeforeExec;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.primitive.LoggerFactory;
+import net.shibboleth.shared.resolver.ResolverException;
+
+/**
+ * Action forming {@link CredentialIssuerMetadataSuccessResponse} from the
+ * document the attached {@link CredentialIssuerMetadataResolver} resolves.
+ *
+ */
+public class FormOutboundIssuerMetadataResponseMessage extends AbstractProfileAction {
+
+ /** Class logger. */
+ @Nonnull
+ private final Logger log = LoggerFactory.getLogger(FormOutboundIssuerMetadataResponseMessage.class);
+
+ /** Resolver of the metadata that is being published. */
+ @NonnullAfterInit
+ private CredentialIssuerMetadataResolver metadataResolver;
+
+ /** Metadata to publish. */
+ @NonnullBeforeExec
+ private CredentialIssuerMetadata metadata;
+
+ /**
+ * Set the resolver of the metadata that is being published.
+ *
+ * @param resolver resolver to use
+ */
+ public void setMetadataResolver(@Nonnull final CredentialIssuerMetadataResolver resolver) {
+ checkSetterPreconditions();
+ metadataResolver = Constraint.isNotNull(resolver, "The metadata resolver cannot be null");
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ protected void doInitialize() throws ComponentInitializationException {
+ super.doInitialize();
+
+ if (metadataResolver == null) {
+ throw new ComponentInitializationException("The metadata resolver cannot be null");
+ }
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+
+ if (!super.doPreExecute(profileRequestContext)) {
+ return false;
+ }
+
+ try {
+ metadata = metadataResolver.resolveSingle(profileRequestContext);
+ } catch (final ResolverException e) {
+ log.error("{} Could not resolve Credential Issuer metadata", getLogPrefix(), e);
+ ActionSupport.buildEvent(profileRequestContext, EventIds.IO_ERROR);
+ return false;
+ }
+ if (metadata == null) {
+ log.error("{} Could not resolve Credential Issuer metadata", getLogPrefix());
+ ActionSupport.buildEvent(profileRequestContext, EventIds.IO_ERROR);
+ return false;
+ }
+
+ final boolean endpoint = hasMember(profileRequestContext, CredentialIssuerMetadata.CREDENTIAL_ENDPOINT);
+ final boolean configurations = hasMember(profileRequestContext,
+ CredentialIssuerMetadata.CREDENTIAL_CONFIGURATIONS_SUPPORTED);
+
+ return endpoint && configurations;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+
+ try {
+ profileRequestContext.ensureOutboundMessageContext()
+ .setMessage(new CredentialIssuerMetadataSuccessResponse(metadata.getMembers()));
+ } catch (final JsonProcessingException e) {
+ log.error("{} Could not serialize Credential Issuer metadata", getLogPrefix(), e);
+ ActionSupport.buildEvent(profileRequestContext, EventIds.IO_ERROR);
+ return;
+ }
+
+ log.debug("{} Publishing Credential Issuer metadata of {}, members {}", getLogPrefix(),
+ metadata.getCredentialIssuer(), metadata.getMembers().keySet());
+ }
+
+ /**
+ * Check the document has a member, building an event when it does not.
+ *
+ * @param profileRequestContext current profile request context
+ * @param member member the document cannot be published without
+ * @return whether the member is present
+ */
+ private boolean hasMember(@Nonnull final ProfileRequestContext profileRequestContext,
+ @Nonnull final String member) {
+ if (metadata.hasMember(member)) {
+ return true;
+ }
+ log.error("{} Credential Issuer metadata has no '{}' member, refusing to publish it", getLogPrefix(), member);
+ ActionSupport.buildEvent(profileRequestContext, EventIds.IO_ERROR);
+ return false;
+ }
+
+}
diff --git a/openid-vci-impl/src/main/resources/META-INF/net.shibboleth.idp/postconfig.xml b/openid-vci-impl/src/main/resources/META-INF/net.shibboleth.idp/postconfig.xml
index 2b4f3f7..00f3c22 100644
--- a/openid-vci-impl/src/main/resources/META-INF/net.shibboleth.idp/postconfig.xml
+++ b/openid-vci-impl/src/main/resources/META-INF/net.shibboleth.idp/postconfig.xml
@@ -28,4 +28,72 @@
class="org.geant.shibboleth.plugin.openidvci.profile.logic.OpenIDVCITokenManipulationStrategy" />
+ <!--
+ Resolver of the Credential Issuer metadata this deployment publishes. The document is
+ read from a file, apart from the members resolved outside it. Deployer may replace the
+ whole file with bean "openidvci.CredentialIssuerMetadata", or point
+ openidvci.issuerMetadata.template at another path.
+
+ The file is a Velocity template, so that the urls in it name this host without the file
+ being edited. Variables are $issuer, $baseUrl and $host, all derived from the issuer of
+ the OP. Define map bean "openidvci.issuerMetadata.TemplateContext" to add variables or
+ to replace those three. A document that carries '$' or '#' as content needs
+ openidvci.issuerMetadata.templated set to false, Velocity reads both of them.
+ -->
+ <bean id="openidvci.DefaultCredentialIssuerMetadataResolver" lazy-init="true"
+ class="org.geant.shibboleth.plugin.openidvci.metadata.impl.DynamicFilesystemCredentialIssuerMetadataResolver"
+ depends-on="shibboleth.LoggingService"
+ p:id="openidvci.DefaultCredentialIssuerMetadataResolver"
+ p:minRefreshDelay="%{openidvci.issuerMetadata.minRefreshDelay:PT5M}"
+ p:maxRefreshDelay="%{openidvci.issuerMetadata.maxRefreshDelay:PT4H}"
+ c:metadata="#{getObject('openidvci.CredentialIssuerMetadata') ?: getObject('openidvci.IssuerMetadataSkeleton')}"
+ p:dynamicValueResolvers-ref="#{'%{openidvci.issuerMetadata.resolver.values:openidvci.issuerMetadata.DefaultDynamicValueResolvers}'.trim()}"
+ p:velocityEngine="#{%{openidvci.issuerMetadata.templated:true} ? getObject('shibboleth.VelocityEngine') : null}"
+ p:issuer="#{getObject('shibboleth.oidc.issuer')}"
+ p:templateContext="#{getObject('openidvci.issuerMetadata.TemplateContext')}" />
+
+ <bean id="openidvci.IssuerMetadataSkeleton" class="org.springframework.core.io.FileSystemResource" lazy-init="true"
+ c:path="%{openidvci.issuerMetadata.template:%{idp.home}/static/openid-credential-issuer.json}" />
+
+ <!--
+ Members of the published document resolved outside the metadata file, keyed by the
+ member each resolver serves. A resolved value replaces the one in the file, so the file
+ does not have to carry the member at all.
+ -->
+ <bean id="openidvci.issuerMetadata.DefaultDynamicValueResolvers" lazy-init="true"
+ class="org.springframework.beans.factory.config.MapFactoryBean">
+ <property name="sourceMap">
+ <map>
+ <entry key="credential_configurations_supported"
+ value-ref="openidvci.issuerMetadata.CredentialConfigurationsResolver" />
+ </map>
+ </property>
+ </bean>
+
+ <!--
+ Credential Configurations are published from the same file that instructs how a
+ credential is formed, so that what is advertised is what can be issued. This only
+ adapts the resolver below to a metadata member.
+ -->
+ <bean id="openidvci.issuerMetadata.CredentialConfigurationsResolver" lazy-init="true"
+ class="org.geant.shibboleth.plugin.openidvci.metadata.impl.CredentialConfigurationsValueResolver"
+ depends-on="shibboleth.LoggingService"
+ p:id="openidvci.issuerMetadata.CredentialConfigurationsResolver"
+ c:credentialConfigurations-ref="openidvci.CredentialConfigurationsResolver" />
+
+ <!--
+ Credential Configurations of this deployment. Every flow reading them shares this, the
+ file is read when it changes rather than once per request.
+ -->
+ <bean id="openidvci.CredentialConfigurationsResolver" lazy-init="true"
+ class="org.geant.shibboleth.plugin.openidvci.credential.impl.FilesystemCredentialConfigurationsResolver"
+ depends-on="shibboleth.LoggingService"
+ p:id="openidvci.CredentialConfigurationsResolver"
+ p:minRefreshDelay="%{openidvci.credentials.minRefreshDelay:PT5M}"
+ p:maxRefreshDelay="%{openidvci.credentials.maxRefreshDelay:PT4H}"
+ c:credentials-ref="openidvci.CredentialsResource" />
+
+ <bean id="openidvci.CredentialsResource" class="org.springframework.core.io.FileSystemResource" lazy-init="true"
+ c:path="%{openidvci.credentials:%{idp.home}/metadata/verifiable-credentials.json}" />
+
</beans>
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credential-offer/credential-offer-beans.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credential-offer/credential-offer-beans.xml
index ae555cf..f6af2fe 100644
--- a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credential-offer/credential-offer-beans.xml
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credential-offer/credential-offer-beans.xml
@@ -24,7 +24,7 @@
scope="prototype"/>
<bean id="ValidateCredentialOffering" class="org.geant.shibboleth.plugin.openidvci.profile.impl.ValidateCredentialOffering"
- scope="prototype" p:credentialsResource="%{idp.home}/metadata/verifiable-credentials.json"/>
+ scope="prototype" p:credentialConfigurationsResolver-ref="openidvci.CredentialConfigurationsResolver"/>
<bean id="BuildCredentialOfferToken" class="org.geant.shibboleth.plugin.openidvci.profile.impl.BuildCredentialOfferToken"
p:issuerLookupStrategy-ref="shibboleth.ResponderIdLookup.Simple"
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credentials/credentials-beans.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credentials/credentials-beans.xml
index 80a9570..d4cb317 100644
--- a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credentials/credentials-beans.xml
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credentials/credentials-beans.xml
@@ -32,7 +32,7 @@
<bean id="ValidateRequestedCredential"
class="org.geant.shibboleth.plugin.openidvci.profile.impl.ValidateRequestedCredential"
- p:credentialsResource="%{idp.home}/metadata/verifiable-credentials.json" scope="prototype" />
+ p:credentialConfigurationsResolver-ref="openidvci.CredentialConfigurationsResolver" scope="prototype" />
<bean id="ParseProof"
class="org.geant.shibboleth.plugin.openidvci.profile.impl.ParseProof" scope="prototype" />
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/issuer-metadata/issuer-metadata-beans.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/issuer-metadata/issuer-metadata-beans.xml
new file mode 100644
index 0000000..3d5fc83
--- /dev/null
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/issuer-metadata/issuer-metadata-beans.xml
@@ -0,0 +1,39 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<beans
+ xmlns="http://www.springframework.org/schema/beans"
+ xmlns:c="http://www.springframework.org/schema/c"
+ xmlns:context="http://www.springframework.org/schema/context"
+ xmlns:p="http://www.springframework.org/schema/p"
+ xmlns:util="http://www.springframework.org/schema/util"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
+ http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd
+ http://www.springframework.org/schema/util http://www.springframework.org/schema/util/spring-util.xsd"
+ default-init-method="initialize" default-destroy-method="destroy">
+
+ <bean id="shibboleth.openidvci.profileId" class="java.lang.String"
+ c:_0="http://geant.org/ns/profiles/openid/vci/issuer-metadata"/>
+
+ <bean id="InitializeUnverifiedRelyingPartyContext"
+ class="net.shibboleth.idp.plugin.oidc.op.profile.impl.InitializeUnverifiedRelyingPartyContext" scope="prototype" />
+
+ <bean id="FormOutboundMessage"
+ class="org.geant.shibboleth.plugin.openidvci.profile.impl.FormOutboundIssuerMetadataResponseMessage"
+ scope="prototype"
+ p:metadataResolver-ref="#{'%{openidvci.issuerMetadata.resolver:openidvci.DefaultCredentialIssuerMetadataResolver}'.trim()}" />
+
+ <bean id="BuildErrorResponseFromEvent" class="net.shibboleth.oidc.profile.impl.BuildJSONErrorResponseFromEvent"
+ scope="prototype" p:defaultStatusCode="500" p:defaultCode="server_error"
+ p:httpServletResponseSupplier-ref="shibboleth.HttpServletResponseSupplier">
+ <property name="eventContextLookupStrategy">
+ <bean class="net.shibboleth.idp.profile.context.navigate.WebFlowCurrentEventLookupFunction" />
+ </property>
+ <property name="mappedErrors">
+ <map value-type="com.nimbusds.oauth2.sdk.ErrorObject">
+ <entry key="#{T(org.opensaml.profile.action.EventIds).IO_ERROR}"
+ value="#{T(com.nimbusds.oauth2.sdk.OAuth2Error).SERVER_ERROR}" />
+ </map>
+ </property>
+ </bean>
+
+</beans>
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/issuer-metadata/issuer-metadata-flow.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/issuer-metadata/issuer-metadata-flow.xml
new file mode 100644
index 0000000..850637d
--- /dev/null
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/issuer-metadata/issuer-metadata-flow.xml
@@ -0,0 +1,27 @@
+<?xml version="1.0"?>
+<flow xmlns="http://www.springframework.org/schema/webflow"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://www.springframework.org/schema/webflow http://www.springframework.org/schema/webflow/spring-webflow.xsd"
+ parent="openid/vci/abstract-api">
+
+ <!--
+ Publishes Credential Issuer metadata. There is no inbound message to decode and no
+ client to authenticate, a wallet reads this before it knows anything about us. So the
+ relying party is the unverified one, same as with the status list endpoint.
+ -->
+ <action-state id="InitializeMandatoryContexts">
+ <evaluate expression="InitializeProfileRequestContext" />
+ <evaluate expression="PopulateMetricContext" />
+ <evaluate expression="FlowStartPopulateAuditContext" />
+ <evaluate expression="InitializeOutboundMessageContext" />
+ <evaluate expression="InitializeUnverifiedRelyingPartyContext" />
+ <evaluate expression="SelectRelyingPartyConfiguration" />
+ <evaluate expression="SelectProfileConfiguration" />
+ <evaluate expression="'proceed'" />
+
+ <transition on="proceed" to="BuildResponseMessage" />
+ </action-state>
+
+ <bean-import resource="classpath:/META-INF/net/shibboleth/idp/flows/openid/vci/issuer-metadata/issuer-metadata-beans.xml" />
+
+</flow>
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/service/relying-party/postconfig.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/service/relying-party/postconfig.xml
index 7ef642a..38b07a9 100644
--- a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/service/relying-party/postconfig.xml
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/service/relying-party/postconfig.xml
@@ -48,6 +48,14 @@
<bean id="OpenID.VCI.Credentials" parent="AbstractVCIProfile" lazy-init="true"
class="org.geant.shibboleth.plugin.openidvci.config.impl.DefaultOpenIDVCICredentialsConfiguration" />
+ <!--
+ Endpoint publishing Credential Issuer metadata. Serves wallets before there is a
+ client, so there is nothing of the VCI profile settings to inherit here.
+ -->
+ <bean id="OpenID.VCI.IssuerMetadata" lazy-init="true"
+ class="org.geant.shibboleth.plugin.openidvci.config.impl.DefaultOpenIDVCIIssuerMetadataConfiguration"
+ p:securityConfiguration-ref="%{idp.security.oidc.config:shibboleth.oidc.DefaultSecurityConfiguration}" />
+
<bean id="OAuth.StatusList" lazy-init="true"
class="org.geant.shibboleth.plugin.oauth.statuslist.config.DefaultStatusListConfiguration"
p:securityConfiguration-ref="%{idp.security.oidc.config:shibboleth.oidc.DefaultSecurityConfiguration}"
diff --git a/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/conf/openid-vci-credentials.xml b/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/conf/openid-vci-credentials.xml
index 1f72c13..11c82b3 100644
--- a/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/conf/openid-vci-credentials.xml
+++ b/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/conf/openid-vci-credentials.xml
@@ -10,24 +10,54 @@
default-init-method="initialize" default-destroy-method="destroy">
<!--
- This file defines with which key issued Verifiable Credentials are signed. This file
- should be imported into credentials.xml.
+ Signing keys of this plugin. Nothing here is active by default: Credentials are signed
+ with the OP's own response signing credentials, the ones in
+ shibboleth.oidc.SigningCredentials, and keeping it that way needs nothing to be done.
+ Uncomment below whichever function you want to give a key of its own.
- Nothing here is active by default. Credentials are signed with OP's own response
- signing credentials, the ones in shibboleth.oidc.SigningCredentials. Keeping it that
- way needs nothing to be done.
+ Two functions of this plugin sign, and each of them can have its own key:
- Uncomment beans below to sign Credentials with own key instead. Note that Credential
- is verified long after issuing it, by wallet or verifier having no relation to your
- OP. Own key is not private matter between your deployment and its clients. See
- "Publishing" below.
+ Credential signing this file, openidvci.SigningCredentials
+ Read by a wallet or verifier that has no relation to your deployment, long after
+ issuing. Needs publishing if separated, see "Publishing" below.
+
+ Status List Token signing this file, oauth.statuslist.SigningCredentials
+ Read by the same verifiers. Constrained, see the section below.
+
+ The OP's own response signing credentials, shibboleth.oidc.SigningCredentials in
+ conf/oidc-credentials.xml, are what both fall back to.
+
+ The defaults share one key across these because a plugin cannot generate keys for you.
+ Separating them narrows what rotating or losing a key affects, which is the reason to
+ bother. Generate each one with bin/jwtgen.sh, the same way as the OP's own keys.
+ -->
+
+ <!--
+ Import this file into conf/credentials.xml.
+
+ Import it into conf/global.xml instead if you state this deployment in OpenID
+ Federation, see conf/openid-vci-oidfed.xml. Credential signing reads the list either
+ way, publishing it in an Entity Configuration only works from the root context. The two
+ go together: the federation file alone refuses to start without the list.
+
+ Import it in ONE place, not both. The relying party service is a child of the root
+ context and reads beans from it, so everything signing Credentials still finds the list
+ when it is declared in conf/global.xml. Importing this file in both places declares the
+ same bean ids twice, and the child copies shadow the root ones: Credential signing would
+ then use one set of credential objects and the Entity Configuration another. Reloading
+ conf/credentials.xml could move signing to a new key while vc_issuer still states the old
+ one, which is the drift this arrangement exists to prevent.
+
+ The cost of moving the import is reload. conf/credentials.xml belongs to the relying party
+ service, which is reloadable, so a key listed there can be changed without a restart.
+ conf/global.xml is the root context and is not reloadable. Federating therefore makes key
+ changes a restart, which is why the import location is left as credentials.xml by default.
-->
<!--
Signing key of Verifiable Credentials only. Must be EC key, signing actions support
- nothing else, and it should match one of algorithms you advertise in
- credential_signing_alg_values_supported of your Credential Issuer metadata. Generate
- one with bin/jwtgen.sh, same way as OP's own keys.
+ nothing else, and it should match one of the algorithms you advertise in
+ credential_signing_alg_values_supported of your Credential Issuer metadata.
-->
<!--
<bean id="openidvci.DefaultESSigningCredential" parent="shibboleth.JWKCredential"
@@ -36,11 +66,11 @@
<!--
Lists ALL credentials used for signing issued Verifiable Credentials. Defining this
- list REPLACES OP's credentials in Credential signing, it does not add to them. So list
- every key you want to be considered. First one compatible with used algorithm is the
- one signing.
+ list REPLACES the OP's credentials in Credential signing, it does not add to them. So
+ list every key you want considered. The first one compatible with the algorithm in use
+ is the one signing.
- Leave this undefined to keep sharing OP's signing credentials.
+ Leave this undefined to keep sharing the OP's signing credentials.
-->
<!--
<util:list id="openidvci.SigningCredentials">
@@ -49,32 +79,39 @@
-->
<!--
- Status List Tokens are signed separately from Credentials. Status list is general
- purpose mechanism and does not serve only Verifiable Credentials. By default they are
- signed with same key as Credentials, so nothing needs to be done here.
-
- Define this list ONLY if status list must be signed with own key. Status List Token
- names same issuer as Credentials referring to it. While that issuer is did:jwk, issuer
- IS the key: separating them makes your Credentials refer to status list that looks like
- issued by somebody else. Separate them only when openidvci.issuer names real issuer.
+ Signing key of Status List Tokens. A status list is a general purpose mechanism and does
+ not serve only Verifiable Credentials, so it has its own key where it can.
+
+ IT CANNOT WHILE THE ISSUER IS A did:jwk, which is the default. A Status List Token names
+ the same issuer as the Credentials referring to it, and while that issuer is a did:jwk
+ the issuer IS the signing key: a separate key would make your Credentials point at a
+ status list that reads as issued by somebody else. So uncomment these two only once
+ openidvci.issuer in conf/openid-vci.properties names a real issuer. Until then the
+ Credential signing key above signs Status List Tokens too.
-->
<!--
+ <bean id="oauth.statuslist.DefaultESSigningCredential" parent="shibboleth.JWKCredential"
+ p:resource="%{idp.signing.oauth.statuslist.es.key}" />
+
<util:list id="oauth.statuslist.SigningCredentials">
- <ref bean="openidvci.DefaultESSigningCredential" />
+ <ref bean="oauth.statuslist.DefaultESSigningCredential" />
</util:list>
-->
<!--
Publishing.
- Wallet or verifier finds your public key from key set OP publishes in its jwks_uri.
- That key set has shibboleth.oidc.SigningCredentials in it, so key defined above is NOT
- published. Credentials signed with it CANNOT be verified before you say otherwise.
+ A wallet or verifier finds your public key from the key set the OP publishes in its
+ jwks_uri. That key set holds shibboleth.oidc.SigningCredentials, so the key defined
+ above is NOT published. Credentials signed with it CANNOT be verified before you say
+ otherwise.
- To publish it, define shibboleth.oidc.SigningCredentialsToPublish in oidc-credentials.xml.
- That bean REPLACES published set also, it does not add to it. So it must list OP's own
- signing credentials too, or clients can not verify id tokens anymore. List looks like
- this:
+ This matters only if you separated the key above. Publishing it in an OpenID Federation
+ Entity Configuration is one way, see
+ conf/openid-vci-oidfed.xml. Publishing it in the OP's key set is the other: define
+ shibboleth.oidc.SigningCredentialsToPublish in oidc-credentials.xml. That bean REPLACES
+ the published set, it does not add to it, so it must list the OP's own signing
+ credentials too or clients can no longer verify id tokens. The list looks like this:
<util:list id="shibboleth.oidc.SigningCredentialsToPublish">
<ref bean="shibboleth.oidc.DefaultRSSigningCredential" />
@@ -82,12 +119,13 @@
<ref bean="openidvci.DefaultESSigningCredential" />
</util:list>
- Note that same list is also set of keys trusted in validation of JWTs your deployment
- has issued to itself, logout tokens for example. Credential signing key becomes trusted
- there too.
+ Note that the same list is also the set of keys trusted when validating JWTs your
+ deployment has issued to itself, logout tokens for example. The Credential signing key
+ becomes trusted there too. Publishing through the federation avoids that, and is why
+ ...ToPublish is the second choice rather than the first.
- Publish the key before you start signing with it. Verifier having fetched key set
- earlier may still have old one in cache.
+ Publish the key before you start signing with it. A verifier that fetched the key set
+ earlier may still have the old one cached.
-->
</beans>
diff --git a/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/conf/openid-vci-oidfed.xml b/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/conf/openid-vci-oidfed.xml
new file mode 100644
index 0000000..a7fd47e
--- /dev/null
+++ b/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/conf/openid-vci-oidfed.xml
@@ -0,0 +1,101 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<beans xmlns="http://www.springframework.org/schema/beans"
+ xmlns:context="http://www.springframework.org/schema/context"
+ xmlns:util="http://www.springframework.org/schema/util" xmlns:p="http://www.springframework.org/schema/p"
+ xmlns:c="http://www.springframework.org/schema/c" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
+ http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd
+ http://www.springframework.org/schema/util http://www.springframework.org/schema/util/spring-util.xsd"
+
+ default-init-method="initialize" default-destroy-method="destroy">
+
+ <!--
+ This file states in OpenID Federation that this deployment is a Credential Issuer. It
+ should be imported into conf/global.xml.
+
+ IMPORT IT ONLY IF THE OpenID Federation PLUGIN IS INSTALLED. The bean below implements
+ an interface that ships with that plugin, so without it the bean cannot be created and
+ the IdP will not start.
+
+ Nothing here is active by default, importing the file is what turns it on. Once
+ imported, the federation module picks the bean up on its own, there is no list of
+ decorators to add it to. It collects every bean of the decorator type it can see.
+
+ Three entity types are stated:
+
+ openid_credential_issuer
+ The Credential Issuer metadata, the same document /.well-known/openid-credential-issuer
+ serves. It comes from the same resolver, so the two cannot drift apart. A member
+ wanted only in the federation, "authorization_endpoint" for example, is added to
+ static/openid-credential-issuer.json and needs nothing here.
+
+ vc_issuer
+ A JWK Set of the keys Credentials are signed with, public halves only. This is how
+ a verifier finds the key: it reads a Credential long after issuing, and has no
+ relation to this deployment.
+
+ The keys are listed below, and the list has to be here rather than resolved from the
+ request. An entity configuration describes the entity, and the federation module
+ caches one document per deployment, so nothing in it may depend on a relying party.
+
+ federation_entity
+ display_name and logo_uri, taken from the display properties of the Credential
+ Issuer metadata. Only members the federation module's own entity configuration
+ metadata does not already carry are filled. Set decorateFederationEntity to false
+ to state federation_entity entirely in that file instead.
+ -->
+
+ <!--
+ The keys stated in vc_issuer are read from bean "openidvci.SigningCredentials", the same
+ bean the Credential signing configuration reads. One declaration, so what is published
+ is what signs.
+
+ THAT BEAN HAS TO BE DECLARED WHERE THE ROOT CONTEXT READS IT, which means importing
+ conf/openid-vci-credentials.xml into conf/global.xml rather than into conf/credentials.xml.
+ Credential signing lives in the reloadable relying party service, a child of the root
+ context, so it still finds the bean; the other way around does not work, which is why
+ this file cannot simply point at openidvci.SigningConfiguration. The plugin refuses to
+ start if the bean is not there: a Credential Issuer stating no key in a federation is a
+ deployment error, not something to find out from a verifier that cannot read a Credential.
+
+ The plugin's default, sharing the OP's response signing credentials, cannot be read here
+ directly: bean shibboleth.oidc.SigningCredentialsFactory lives in the relying party
+ service. The keys behind it can be, though. They are JWK files named by properties, so
+ declaring the list against the same property keeps one source:
+
+ <util:list id="openidvci.SigningCredentials">
+ <bean parent="shibboleth.JWKCredential" p:resource="%{idp.signing.oidc.es.key}" />
+ </util:list>
+
+ List the EC key only. Credentials are signed with EC algorithms and nothing else, so an
+ RSA key in this list would be stated in vc_issuer as a key that signs Credentials while
+ never signing one. Note also that this pins the key: rotating the OP's key by pointing
+ conf/oidc-credentials.xml at another resource would leave this list behind.
+
+ Preferring a key of your own is still the better answer. A key published in a federation
+ as a Credential signing key should sign Credentials and nothing else, so generate one
+ with bin/jwtgen.sh and enable it in conf/openid-vci-credentials.xml.
+ -->
+ <bean id="openidvci.oidfed.CredentialIssuerEntityConfigurationMetadataDecorator"
+ class="org.geant.shibboleth.plugin.openidvci.oidfed.impl.CredentialIssuerEntityConfigurationMetadataDecorator"
+ p:id="openidvci.oidfed.CredentialIssuerEntityConfigurationMetadataDecorator"
+ p:metadataResolver-ref="#{'%{openidvci.issuerMetadata.resolver:openidvci.DefaultCredentialIssuerMetadataResolver}'.trim()}"
+ p:credentialSigningCredentials="#{getObject('openidvci.SigningCredentials')}"
+ p:decorateFederationEntity="%{openidvci.oidfed.decorateFederationEntity:true}" />
+
+ <!--
+ The entity statement itself is signed with the federation module's own key, not with the
+ keys above. Section 5.2.1 of OpenID Federation names them as distinct: the federation
+ key identifies the entity to the federation, the keys above let a verifier read a
+ Credential. Keep them separate.
+
+ Worth checking once, because the federation module signs with
+ shibboleth.oidfed.SigningCredentials and falls back to shibboleth.oidc.SigningCredentials
+ when that is undefined. A Credential signing key that is also in the OP's list therefore
+ ends up trusted to sign entity statements. It shows in the published statement: the top
+ level "jwks" claim is the Federation Entity Keys, and a Credential signing key belongs in
+ metadata.vc_issuer.jwks only. Declaring shibboleth.oidfed.SigningCredentials explicitly
+ separates them.
+ -->
+
+</beans>
diff --git a/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/conf/openid-vci.properties b/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/conf/openid-vci.properties
index fe0350a..6b93c88 100644
--- a/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/conf/openid-vci.properties
+++ b/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/conf/openid-vci.properties
@@ -1,9 +1,38 @@
oauth.statuslist.indexPermutationKey=change_this_to_secret
oauth.statuslist.ttl=PT1H
oauth.statuslist.assignmentRetention=P7D
+#Openid Issuer in credentials. Default to did:jwk
openidvci.issuer=
-# Signing key of issued Verifiable Credentials. Used only if you enable credential of same
-# name in conf/openid-vci-credentials.xml. Until that, Credentials are signed with OP's own
-# signing credentials. Must be EC key.
+# Signing key of issued Verifiable Credentials. Used only if you enable the credential of the
+# same name in conf/openid-vci-credentials.xml. Until that, Credentials are signed with the
+# OP's own signing credentials. Must be EC key.
idp.signing.openidvci.es.key = %{idp.home}/credentials/openid-vci-signing-es.jwk
+
+# Signing key of Status List Tokens. Used only if you enable the credential of the same name
+# in conf/openid-vci-credentials.xml. Must be EC key.
+idp.signing.oauth.statuslist.es.key = %{idp.home}/credentials/oauth-statuslist-signing-es.jwk
+
+# Credential Configurations. Published in Credential Issuer metadata and used as the
+# instruction on how a credential is formed. Every flow reads them from here, through one
+# resolver, so the file is read when it changes and not once per request.
+#openidvci.credentials = %{idp.home}/metadata/verifiable-credentials.json
+#openidvci.credentials.minRefreshDelay = PT5M
+#openidvci.credentials.maxRefreshDelay = PT4H
+
+# Settings for the Credential Issuer metadata flow
+# Template for metadata
+#openidvci.issuerMetadata.template = %{idp.home}/static/openid-credential-issuer.json
+# The template is Velocity, its variables are $issuer, $baseUrl and $host, derived from the
+# issuer of the OP.
+#openidvci.issuerMetadata.templated = true
+#openidvci.issuerMetadata.resolver = openidvci.DefaultCredentialIssuerMetadataResolver
+#openidvci.issuerMetadata.resolver.values = openidvci.issuerMetadata.DefaultDynamicValueResolvers
+#openidvci.issuerMetadata.minRefreshDelay = PT5M
+#openidvci.issuerMetadata.maxRefreshDelay = PT4H
+
+# Settings for stating this deployment as a Credential Issuer in OpenID Federation. Active
+# only when conf/openid-vci-oidfed.xml is imported into conf/global.xml. Set to false to
+# state federation_entity entirely in the federation module's own metadata file instead of
+# filling display_name and logo_uri from Credential Issuer metadata.
+#openidvci.oidfed.decorateFederationEntity = true
diff --git a/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/module.properties b/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/module.properties
index f65119b..8608bf1 100644
--- a/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/module.properties
+++ b/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/module.properties
@@ -16,6 +16,20 @@ OpenID-VCI.1.dest = conf/openid-vci.properties
OpenID-VCI.2.src = /org/geant/shibboleth/plugin/openidvci/conf/openid-vci-credentials.xml
OpenID-VCI.2.dest = conf/openid-vci-credentials.xml
+OpenID-VCI.3.src = /org/geant/shibboleth/plugin/openidvci/static/openid-credential-issuer.json
+OpenID-VCI.3.dest = static/openid-credential-issuer.json
+
+OpenID-VCI.4.src = /org/geant/shibboleth/plugin/openidvci/conf/openid-vci-oidfed.xml
+OpenID-VCI.4.dest = conf/openid-vci-oidfed.xml
+
OpenID-VCI.postenable = Add <import resource="openid-vci-credentials.xml" /> in conf/credentials.xml \
-if you want to sign Verifiable Credentials with own key. Until that, Credentials are signed with \
-OP's own signing credentials.
+if you want to sign Verifiable Credentials, or Status List Tokens, with keys of their own. Until \
+that, Credentials are signed with OP's own signing credentials. That file lists every signing \
+function of this plugin and the key each one can be given. \
+Wire /.well-known/openid-credential-issuer to /idp/profile/openid/vci/issuer-metadata to publish \
+Credential Issuer metadata. Urls in static/openid-credential-issuer.json name your host already, \
+it is a Velocity template evaluated against the issuer of the OP. Credential Configurations are not \
+kept in that file, they are published from metadata/verifiable-credentials.json. \
+If you run the OpenID Federation plugin, add <import resource="openid-vci-oidfed.xml" /> in \
+conf/global.xml to state this deployment as a Credential Issuer in its Entity Configuration. Do \
+not import it without that plugin, the IdP will not start.
diff --git a/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/static/openid-credential-issuer.json b/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/static/openid-credential-issuer.json
new file mode 100644
index 0000000..12d7144
--- /dev/null
+++ b/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/static/openid-credential-issuer.json
@@ -0,0 +1,14 @@
+{
+ "credential_issuer":"$baseUrl",
+ "authorization_servers":[
+ "$issuer"
+ ],
+ "credential_endpoint":"$baseUrl/idp/profile/openid/vci/credentials",
+ "nonce_endpoint":"$baseUrl/idp/profile/openid/vci/nonce",
+ "display":[
+ {
+ "name":"$host",
+ "locale":"en-US"
+ }
+ ]
+}
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/credential/impl/FilesystemCredentialConfigurationsResolverTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/credential/impl/FilesystemCredentialConfigurationsResolverTest.java
new file mode 100644
index 0000000..c11b16c
--- /dev/null
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/credential/impl/FilesystemCredentialConfigurationsResolverTest.java
@@ -0,0 +1,99 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * 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 org.geant.shibboleth.plugin.openidvci.credential.impl;
+
+import java.util.Map;
+import java.util.Set;
+
+import org.geant.shibboleth.plugin.openidvci.credential.CredentialConfigurations;
+import org.springframework.core.io.ClassPathResource;
+import org.testng.Assert;
+import org.testng.annotations.Test;
+
+import net.shibboleth.shared.component.ComponentInitializationException;
+
+/**
+ * Unit tests for {@link FilesystemCredentialConfigurationsResolver}.
+ */
+public class FilesystemCredentialConfigurationsResolverTest {
+
+ private FilesystemCredentialConfigurationsResolver resolverOf(final String path) throws Exception {
+ final FilesystemCredentialConfigurationsResolver resolver = new FilesystemCredentialConfigurationsResolver(
+ new ClassPathResource(path));
+ resolver.setId("test");
+ resolver.initialize();
+ return resolver;
+ }
+
+ @Test
+ public void testSuccess() throws Exception {
+ final CredentialConfigurations configurations = resolverOf("conf/verifiable-credentials.json")
+ .resolveSingle(null);
+
+ Assert.assertNotNull(configurations);
+ Assert.assertEquals(configurations.getCredentialConfigurations().keySet(),
+ Set.of("GeantIncubatorDiploma", "GeantIncubatorDiploma2"));
+ Assert.assertEquals(configurations.getSupported().keySet(),
+ Set.of("GeantIncubatorDiploma", "GeantIncubatorDiploma2"));
+ }
+
+ @Test
+ public void testParsedAndSourceAgree() throws Exception {
+ final CredentialConfigurations configurations = resolverOf("conf/verifiable-credentials.json")
+ .resolveSingle(null);
+
+ Assert.assertNotNull(configurations);
+ Assert.assertEquals(configurations.getCredentialConfigurations().get("GeantIncubatorDiploma").getFormat(),
+ "dc+sd-jwt");
+
+ @SuppressWarnings("unchecked")
+ final Map<String, Object> source = (Map<String, Object>) configurations.getSupported()
+ .get("GeantIncubatorDiploma");
+ Assert.assertEquals(source.get("format"), "dc+sd-jwt");
+ Assert.assertEquals(source.get("vct"), "GeantIncubatorDiploma");
+ Assert.assertTrue(source.containsKey("credential_metadata"));
+ }
+
+ @Test
+ public void testTheSameInstanceIsServedUntilTheFileChanges() throws Exception {
+ final FilesystemCredentialConfigurationsResolver resolver = resolverOf("conf/verifiable-credentials.json");
+
+ final CredentialConfigurations first = resolver.resolveSingle(null);
+ resolver.refresh();
+ final CredentialConfigurations second = resolver.resolveSingle(null);
+
+ Assert.assertSame(first, second);
+ }
+
+ @Test
+ public void testNoCredentialConfigurations() {
+ Assert.assertThrows(ComponentInitializationException.class,
+ () -> resolverOf("conf/no-credential-configurations.json"));
+ }
+
+ @Test
+ public void testUnparsableCredentialConfiguration() {
+ Assert.assertThrows(ComponentInitializationException.class,
+ () -> resolverOf("conf/unparsable-credential-configuration.json"));
+ }
+
+ @Test
+ public void testUnparsableFile() {
+ Assert.assertThrows(ComponentInitializationException.class, () -> resolverOf("conf/unparsable.txt"));
+ }
+
+}
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/CredentialConfigurationsValueResolverTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/CredentialConfigurationsValueResolverTest.java
new file mode 100644
index 0000000..39adbb9
--- /dev/null
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/CredentialConfigurationsValueResolverTest.java
@@ -0,0 +1,119 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * 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 org.geant.shibboleth.plugin.openidvci.metadata.impl;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import org.geant.shibboleth.plugin.openidvci.credential.CredentialConfigurations;
+import org.geant.shibboleth.plugin.openidvci.credential.impl.FilesystemCredentialConfigurationsResolver;
+import org.geant.shibboleth.plugin.openidvci.credential.resolver.CredentialConfigurationsResolver;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.springframework.core.io.ClassPathResource;
+import org.testng.Assert;
+import org.testng.annotations.Test;
+
+/**
+ * Unit tests for {@link CredentialConfigurationsValueResolver}.
+ */
+public class CredentialConfigurationsValueResolverTest {
+
+ private CredentialConfigurationsValueResolver adapterOf(final String path) throws Exception {
+ final FilesystemCredentialConfigurationsResolver delegate = new FilesystemCredentialConfigurationsResolver(
+ new ClassPathResource(path));
+ delegate.setId("delegate");
+ delegate.initialize();
+
+ final CredentialConfigurationsValueResolver adapter = new CredentialConfigurationsValueResolver(delegate);
+ adapter.setId("test");
+ adapter.initialize();
+ return adapter;
+ }
+
+ @SuppressWarnings("unchecked")
+ @Test
+ public void testSuccess() throws Exception {
+ final Object resolved = adapterOf("conf/verifiable-credentials.json").resolveSingle(null);
+
+ Assert.assertTrue(resolved instanceof Map);
+ Assert.assertEquals(((Map<String, Object>) resolved).keySet(),
+ Set.of("GeantIncubatorDiploma", "GeantIncubatorDiploma2"));
+ }
+
+ @Test
+ public void testConfigurationIsPublishedAsWritten() throws Exception {
+ @SuppressWarnings("unchecked")
+ final Map<String, Object> resolved = (Map<String, Object>) adapterOf("conf/verifiable-credentials.json")
+ .resolveSingle(null);
+
+ @SuppressWarnings("unchecked")
+ final Map<String, Object> configuration = (Map<String, Object>) resolved.get("GeantIncubatorDiploma");
+
+ Assert.assertEquals(configuration.get("format"), "dc+sd-jwt");
+ Assert.assertEquals(configuration.get("vct"), "GeantIncubatorDiploma");
+ Assert.assertTrue(configuration.containsKey("credential_metadata"));
+ }
+
+ @Test
+ public void testRefreshIsDelegated() throws Exception {
+ final CredentialConfigurationsValueResolver adapter = adapterOf("conf/verifiable-credentials.json");
+
+ adapter.refresh();
+
+ Assert.assertNotNull(adapter.getLastRefresh());
+ Assert.assertNotNull(adapter.getLastUpdate());
+ }
+
+ @Test
+ public void testNothingToPublish() throws Exception {
+ final CredentialConfigurationsValueResolver adapter = new CredentialConfigurationsValueResolver(
+ new EmptyResolver());
+ adapter.setId("test");
+ adapter.initialize();
+
+ Assert.assertNull(adapter.resolveSingle(null));
+ Assert.assertFalse(adapter.resolve(null).iterator().hasNext());
+ // Nothing to refresh, and no failure for it either.
+ adapter.refresh();
+ Assert.assertNull(adapter.getLastRefresh());
+ }
+
+ /** Resolver that has nothing to resolve, and is not refreshable. */
+ private static class EmptyResolver implements CredentialConfigurationsResolver {
+
+ /** {@inheritDoc} */
+ @Override
+ public Iterable<CredentialConfigurations> resolve(final ProfileRequestContext criteria) {
+ return List.of();
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public CredentialConfigurations resolveSingle(final ProfileRequestContext criteria) {
+ return null;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public String getId() {
+ return "empty";
+ }
+
+ }
+
+}
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/DynamicFilesystemCredentialIssuerMetadataResolverTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/DynamicFilesystemCredentialIssuerMetadataResolverTest.java
new file mode 100644
index 0000000..272ab42
--- /dev/null
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/DynamicFilesystemCredentialIssuerMetadataResolverTest.java
@@ -0,0 +1,246 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * 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 org.geant.shibboleth.plugin.openidvci.metadata.impl;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.velocity.app.VelocityEngine;
+import org.apache.velocity.runtime.resource.loader.StringResourceLoader;
+import org.geant.shibboleth.plugin.openidvci.credential.impl.FilesystemCredentialConfigurationsResolver;
+import org.geant.shibboleth.plugin.openidvci.metadata.CredentialIssuerMetadata;
+import org.geant.shibboleth.plugin.openidvci.metadata.resolver.CredentialIssuerMetadataValueResolver;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.springframework.core.io.ClassPathResource;
+import org.testng.Assert;
+import org.testng.annotations.Test;
+
+import net.shibboleth.shared.component.ComponentInitializationException;
+
+/**
+ * Unit tests for {@link DynamicFilesystemCredentialIssuerMetadataResolver}.
+ */
+public class DynamicFilesystemCredentialIssuerMetadataResolverTest {
+
+ private DynamicFilesystemCredentialIssuerMetadataResolver resolverOf(final String path,
+ final Map<String, ? extends CredentialIssuerMetadataValueResolver> dynamic) throws Exception {
+ final DynamicFilesystemCredentialIssuerMetadataResolver resolver = new DynamicFilesystemCredentialIssuerMetadataResolver(
+ new ClassPathResource(path));
+ resolver.setId("test");
+ if (dynamic != null) {
+ resolver.setDynamicValueResolvers(dynamic);
+ }
+ resolver.initialize();
+ return resolver;
+ }
+
+ private DynamicFilesystemCredentialIssuerMetadataResolver templatedResolverOf(final String path,
+ final String issuer) throws Exception {
+ final DynamicFilesystemCredentialIssuerMetadataResolver resolver = new DynamicFilesystemCredentialIssuerMetadataResolver(
+ new ClassPathResource(path));
+ resolver.setId("test");
+ resolver.setVelocityEngine(velocityEngine());
+ resolver.setIssuer(issuer);
+ resolver.initialize();
+ return resolver;
+ }
+
+ private VelocityEngine velocityEngine() {
+ final VelocityEngine engine = new VelocityEngine();
+ engine.setProperty("resource.loaders", "string");
+ engine.setProperty("resource.loader.string.class", StringResourceLoader.class.getName());
+ engine.init();
+ return engine;
+ }
+
+ private CredentialConfigurationsValueResolver credentialConfigurations() throws Exception {
+ final FilesystemCredentialConfigurationsResolver delegate = new FilesystemCredentialConfigurationsResolver(
+ new ClassPathResource("conf/verifiable-credentials.json"));
+ delegate.setId("credentialConfigurationsDelegate");
+ delegate.initialize();
+
+ final CredentialConfigurationsValueResolver resolver = new CredentialConfigurationsValueResolver(delegate);
+ resolver.setId("credentialConfigurations");
+ resolver.initialize();
+ return resolver;
+ }
+
+ @Test
+ public void testFileOnly() throws Exception {
+ final CredentialIssuerMetadata metadata = resolverOf("metadata/openid-credential-issuer.json", null)
+ .resolveSingle(null);
+
+ Assert.assertNotNull(metadata);
+ Assert.assertEquals(metadata.getCredentialIssuer().getValue(), "https://example.org");
+ Assert.assertEquals(metadata.getMembers().get(CredentialIssuerMetadata.CREDENTIAL_ENDPOINT),
+ "https://example.org/idp/profile/openid/vci/credentials");
+ Assert.assertFalse(metadata.hasMember(CredentialIssuerMetadata.CREDENTIAL_CONFIGURATIONS_SUPPORTED));
+ }
+
+ @SuppressWarnings("unchecked")
+ @Test
+ public void testCredentialConfigurationsAreOverlaid() throws Exception {
+ final CredentialIssuerMetadata metadata = resolverOf("metadata/openid-credential-issuer.json",
+ Map.of(CredentialIssuerMetadata.CREDENTIAL_CONFIGURATIONS_SUPPORTED, credentialConfigurations()))
+ .resolveSingle(null);
+
+ Assert.assertNotNull(metadata);
+ final Object configurations = metadata.getMembers()
+ .get(CredentialIssuerMetadata.CREDENTIAL_CONFIGURATIONS_SUPPORTED);
+ Assert.assertTrue(configurations instanceof Map);
+ Assert.assertEquals(((Map<String, Object>) configurations).size(), 2);
+
+ final List<String> members = new ArrayList<>(metadata.getMembers().keySet());
+ Assert.assertEquals(members.subList(0, 5), List.of(CredentialIssuerMetadata.CREDENTIAL_ISSUER,
+ "authorization_servers", CredentialIssuerMetadata.CREDENTIAL_ENDPOINT, "nonce_endpoint", "display"));
+ Assert.assertTrue(members.contains(CredentialIssuerMetadata.CREDENTIAL_CONFIGURATIONS_SUPPORTED));
+ }
+
+ @Test
+ public void testOverlayReplacesTheFileValue() throws Exception {
+ final CredentialIssuerMetadata metadata = resolverOf("metadata/openid-credential-issuer.json",
+ Map.of("nonce_endpoint", new ConstantValueResolver("https://overlaid.example.org/nonce")))
+ .resolveSingle(null);
+
+ Assert.assertNotNull(metadata);
+ Assert.assertEquals(metadata.getMembers().get("nonce_endpoint"), "https://overlaid.example.org/nonce");
+ }
+
+ @Test
+ public void testNoCredentialIssuerInFile() {
+ Assert.assertThrows(ComponentInitializationException.class,
+ () -> resolverOf("metadata/openid-credential-issuer-no-issuer.json", null));
+ }
+
+ @Test
+ public void testTemplateNamesTheHost() throws Exception {
+ final CredentialIssuerMetadata metadata = templatedResolverOf("metadata/openid-credential-issuer-template.json",
+ "https://example.org").resolveSingle(null);
+
+ Assert.assertNotNull(metadata);
+ Assert.assertEquals(metadata.getCredentialIssuer().getValue(), "https://example.org");
+ Assert.assertEquals(metadata.getMembers().get(CredentialIssuerMetadata.CREDENTIAL_ENDPOINT),
+ "https://example.org/idp/profile/openid/vci/credentials");
+ Assert.assertEquals(metadata.getMembers().get("nonce_endpoint"),
+ "https://example.org/idp/profile/openid/vci/nonce");
+ Assert.assertEquals(metadata.getMembers().get("authorization_servers"), List.of("https://example.org"));
+ }
+
+ @Test
+ public void testBaseUrlDropsThePathOfTheIssuer() throws Exception {
+ final CredentialIssuerMetadata metadata = templatedResolverOf("metadata/openid-credential-issuer-template.json",
+ "https://example.org/idp/shibboleth").resolveSingle(null);
+
+ Assert.assertNotNull(metadata);
+ Assert.assertEquals(metadata.getCredentialIssuer().getValue(), "https://example.org");
+ Assert.assertEquals(metadata.getMembers().get(CredentialIssuerMetadata.CREDENTIAL_ENDPOINT),
+ "https://example.org/idp/profile/openid/vci/credentials");
+ Assert.assertEquals(metadata.getMembers().get("authorization_servers"),
+ List.of("https://example.org/idp/shibboleth"));
+ }
+
+ @SuppressWarnings("unchecked")
+ @Test
+ public void testHostVariable() throws Exception {
+ final CredentialIssuerMetadata metadata = templatedResolverOf("metadata/openid-credential-issuer-template.json",
+ "https://example.org:8443").resolveSingle(null);
+
+ Assert.assertNotNull(metadata);
+ Assert.assertEquals(metadata.getCredentialIssuer().getValue(), "https://example.org:8443");
+ final List<Object> display = (List<Object>) metadata.getMembers().get("display");
+ Assert.assertEquals(((Map<String, Object>) display.get(0)).get("name"), "example.org");
+ }
+
+ @Test
+ public void testTemplateContextReplacesADerivedVariable() throws Exception {
+ final DynamicFilesystemCredentialIssuerMetadataResolver resolver = new DynamicFilesystemCredentialIssuerMetadataResolver(
+ new ClassPathResource("metadata/openid-credential-issuer-template.json"));
+ resolver.setId("test");
+ resolver.setVelocityEngine(velocityEngine());
+ resolver.setIssuer("https://example.org");
+ resolver.setTemplateContext(Map.of("baseUrl", "https://vci.example.org"));
+ resolver.initialize();
+
+ final CredentialIssuerMetadata metadata = resolver.resolveSingle(null);
+
+ Assert.assertNotNull(metadata);
+ Assert.assertEquals(metadata.getCredentialIssuer().getValue(), "https://vci.example.org");
+ Assert.assertEquals(metadata.getMembers().get("authorization_servers"), List.of("https://example.org"));
+ }
+
+ @Test
+ public void testTemplateIsNotEvaluatedWithoutAnEngine() throws Exception {
+ final CredentialIssuerMetadata metadata = resolverOf("metadata/openid-credential-issuer-template.json", null)
+ .resolveSingle(null);
+
+ Assert.assertNotNull(metadata);
+ Assert.assertEquals(metadata.getCredentialIssuer().getValue(), "$baseUrl");
+ }
+
+ @Test
+ public void testUnresolvedReferenceIsLeftInPlace() throws Exception {
+ final CredentialIssuerMetadata metadata = templatedResolverOf(
+ "metadata/openid-credential-issuer-unresolved.json", "https://example.org").resolveSingle(null);
+
+ Assert.assertNotNull(metadata);
+ Assert.assertEquals(metadata.getMembers().get(CredentialIssuerMetadata.CREDENTIAL_ENDPOINT),
+ "$notAVariable/credentials");
+ }
+
+ @Test
+ public void testIssuerNamingNoHost() {
+ Assert.assertThrows(ComponentInitializationException.class,
+ () -> templatedResolverOf("metadata/openid-credential-issuer-template.json", "not-a-url"));
+ }
+
+ /** Resolver serving a fixed value, standing in for a deployer supplied one. */
+ private static class ConstantValueResolver implements CredentialIssuerMetadataValueResolver {
+
+ /** Value to serve. */
+ private final Object value;
+
+ /**
+ * Constructor.
+ *
+ * @param constant value to serve
+ */
+ ConstantValueResolver(final Object constant) {
+ value = constant;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public Iterable<Object> resolve(final ProfileRequestContext criteria) {
+ return List.of(value);
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public Object resolveSingle(final ProfileRequestContext criteria) {
+ return value;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public String getId() {
+ return "constant";
+ }
+
+ }
+
+}
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/oidfed/impl/CredentialIssuerEntityConfigurationMetadataDecoratorTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/oidfed/impl/CredentialIssuerEntityConfigurationMetadataDecoratorTest.java
new file mode 100644
index 0000000..31837e6
--- /dev/null
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/oidfed/impl/CredentialIssuerEntityConfigurationMetadataDecoratorTest.java
@@ -0,0 +1,277 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * 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 org.geant.shibboleth.plugin.openidvci.oidfed.impl;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import org.geant.shibboleth.plugin.openidvci.credential.impl.FilesystemCredentialConfigurationsResolver;
+import org.geant.shibboleth.plugin.openidvci.metadata.CredentialIssuerMetadata;
+import org.geant.shibboleth.plugin.openidvci.metadata.impl.CredentialConfigurationsValueResolver;
+import org.geant.shibboleth.plugin.openidvci.metadata.impl.DynamicFilesystemCredentialIssuerMetadataResolver;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.opensaml.security.credential.Credential;
+import org.springframework.core.io.ClassPathResource;
+import org.testng.Assert;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.jwk.Curve;
+import com.nimbusds.jose.jwk.ECKey;
+import com.nimbusds.jose.jwk.KeyUse;
+import com.nimbusds.jose.jwk.gen.ECKeyGenerator;
+
+import net.shibboleth.oidc.security.CredentialConversionUtil;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.logic.ConstraintViolationException;
+
+/**
+ * Unit tests for {@link CredentialIssuerEntityConfigurationMetadataDecorator}.
+ */
+public class CredentialIssuerEntityConfigurationMetadataDecoratorTest {
+
+ private ProfileRequestContext profileRequestCtx;
+
+ private ECKey signingKey;
+
+ @BeforeMethod
+ protected void setUp() throws Exception {
+ signingKey = new ECKeyGenerator(Curve.P_256).keyID("credential-signing")
+ .algorithm(JWSAlgorithm.ES256).keyUse(KeyUse.SIGNATURE).generate();
+
+ // Nothing of the request feeds the document, so a bare context is enough.
+ profileRequestCtx = new ProfileRequestContext();
+ }
+
+ /**
+ * Build an initialized decorator.
+ *
+ * @param federationEntity whether federation_entity is filled
+ * @return initialized decorator
+ * @throws Exception if the decorator cannot be built
+ */
+ private CredentialIssuerEntityConfigurationMetadataDecorator decoratorOf(final boolean federationEntity)
+ throws Exception {
+ final CredentialIssuerEntityConfigurationMetadataDecorator decorator =
+ new CredentialIssuerEntityConfigurationMetadataDecorator();
+ decorator.setId("test");
+ decorator.setMetadataResolver(metadataResolver());
+ decorator.setCredentialSigningCredentials(
+ List.of(CredentialConversionUtil.keyToCredential(signingKey)));
+ decorator.setDecorateFederationEntity(federationEntity);
+ decorator.initialize();
+ return decorator;
+ }
+
+ /**
+ * Build the resolver of the metadata to state, with Credential Configurations
+ * overlaid the way the published document has them.
+ *
+ * @return initialized resolver
+ * @throws Exception if the resolver cannot be built
+ */
+ private DynamicFilesystemCredentialIssuerMetadataResolver metadataResolver() throws Exception {
+ final FilesystemCredentialConfigurationsResolver configurations =
+ new FilesystemCredentialConfigurationsResolver(
+ new ClassPathResource("conf/verifiable-credentials.json"));
+ configurations.setId("credentialConfigurations");
+ configurations.initialize();
+
+ final CredentialConfigurationsValueResolver adapter =
+ new CredentialConfigurationsValueResolver(configurations);
+ adapter.setId("credentialConfigurationsAdapter");
+ adapter.initialize();
+
+ final DynamicFilesystemCredentialIssuerMetadataResolver resolver =
+ new DynamicFilesystemCredentialIssuerMetadataResolver(
+ new ClassPathResource("metadata/openid-credential-issuer.json"));
+ resolver.setId("metadata");
+ resolver.setDynamicValueResolvers(
+ Map.of(CredentialIssuerMetadata.CREDENTIAL_CONFIGURATIONS_SUPPORTED, adapter));
+ resolver.initialize();
+ return resolver;
+ }
+
+ @SuppressWarnings("unchecked")
+ @Test
+ public void testCredentialIssuerEntityType() throws Exception {
+ final Map<String, Map<String, Object>> metadata = new LinkedHashMap<>();
+
+ decoratorOf(true).accept(metadata, profileRequestCtx);
+
+ final Map<String, Object> stated =
+ metadata.get(CredentialIssuerEntityConfigurationMetadataDecorator.OPENID_CREDENTIAL_ISSUER);
+ Assert.assertNotNull(stated);
+ Assert.assertEquals(stated.get(CredentialIssuerMetadata.CREDENTIAL_ISSUER), "https://example.org");
+ Assert.assertEquals(stated.get(CredentialIssuerMetadata.CREDENTIAL_ENDPOINT),
+ "https://example.org/idp/profile/openid/vci/credentials");
+ Assert.assertEquals(stated.get("authorization_servers"), List.of("https://example.org"));
+
+ // The same Credential Configurations the well known endpoint serves.
+ final Map<String, Object> configurations = (Map<String, Object>) stated
+ .get(CredentialIssuerMetadata.CREDENTIAL_CONFIGURATIONS_SUPPORTED);
+ Assert.assertEquals(configurations.keySet(),
+ Set.of("GeantIncubatorDiploma", "GeantIncubatorDiploma2"));
+ }
+
+ @SuppressWarnings("unchecked")
+ @Test
+ public void testVcIssuerEntityTypeCarriesAJwkSet() throws Exception {
+ final Map<String, Map<String, Object>> metadata = new LinkedHashMap<>();
+
+ decoratorOf(true).accept(metadata, profileRequestCtx);
+
+ final Map<String, Object> stated =
+ metadata.get(CredentialIssuerEntityConfigurationMetadataDecorator.VC_ISSUER);
+ Assert.assertNotNull(stated);
+
+ // A JWK Set object, not a bare array, which is what the federation module parses.
+ final Map<String, Object> jwks =
+ (Map<String, Object>) stated.get(CredentialIssuerEntityConfigurationMetadataDecorator.JWKS);
+ Assert.assertNotNull(jwks);
+ final List<Object> keys =
+ (List<Object>) jwks.get(CredentialIssuerEntityConfigurationMetadataDecorator.KEYS);
+ Assert.assertEquals(keys.size(), 1);
+
+ final Map<String, Object> key = (Map<String, Object>) keys.get(0);
+ Assert.assertEquals(key.get("kty"), "EC");
+ Assert.assertEquals(key.get("crv"), "P-256");
+ Assert.assertEquals(key.get("kid"), "credential-signing");
+ Assert.assertEquals(key.get("alg"), "ES256");
+ Assert.assertEquals(key.get("use"), "sig");
+ Assert.assertNotNull(key.get("x"));
+ Assert.assertNotNull(key.get("y"));
+ }
+
+ @Test
+ public void testPrivateKeyIsNotStated() throws Exception {
+ final Map<String, Map<String, Object>> metadata = new LinkedHashMap<>();
+
+ decoratorOf(true).accept(metadata, profileRequestCtx);
+
+ Assert.assertFalse(metadata.toString().contains(signingKey.getD().toString()),
+ "Private key of the credential signing key must not reach the entity configuration");
+ }
+
+ @Test
+ public void testFederationEntityIsFilledFromDisplay() throws Exception {
+ final Map<String, Map<String, Object>> metadata = new LinkedHashMap<>();
+
+ decoratorOf(true).accept(metadata, profileRequestCtx);
+
+ final Map<String, Object> stated =
+ metadata.get(CredentialIssuerEntityConfigurationMetadataDecorator.FEDERATION_ENTITY);
+ Assert.assertNotNull(stated);
+ Assert.assertEquals(stated.get(CredentialIssuerEntityConfigurationMetadataDecorator.DISPLAY_NAME),
+ "Example Credential Issuer");
+ }
+
+ @Test
+ public void testFederationEntityOfTheDeployerWins() throws Exception {
+ final Map<String, Map<String, Object>> metadata = new LinkedHashMap<>();
+ final Map<String, Object> federationEntity = new LinkedHashMap<>();
+ federationEntity.put("organization_name", "Example organization");
+ federationEntity.put(CredentialIssuerEntityConfigurationMetadataDecorator.DISPLAY_NAME, "Set by deployer");
+ metadata.put(CredentialIssuerEntityConfigurationMetadataDecorator.FEDERATION_ENTITY, federationEntity);
+
+ decoratorOf(true).accept(metadata, profileRequestCtx);
+
+ final Map<String, Object> stated =
+ metadata.get(CredentialIssuerEntityConfigurationMetadataDecorator.FEDERATION_ENTITY);
+ Assert.assertEquals(stated.get(CredentialIssuerEntityConfigurationMetadataDecorator.DISPLAY_NAME),
+ "Set by deployer");
+ Assert.assertEquals(stated.get("organization_name"), "Example organization");
+ }
+
+ @Test
+ public void testFederationEntityIsLeftAloneWhenTurnedOff() throws Exception {
+ final Map<String, Map<String, Object>> metadata = new LinkedHashMap<>();
+
+ decoratorOf(false).accept(metadata, profileRequestCtx);
+
+ Assert.assertFalse(
+ metadata.containsKey(CredentialIssuerEntityConfigurationMetadataDecorator.FEDERATION_ENTITY));
+ // The other two entity types are still stated.
+ Assert.assertTrue(
+ metadata.containsKey(CredentialIssuerEntityConfigurationMetadataDecorator.OPENID_CREDENTIAL_ISSUER));
+ Assert.assertTrue(metadata.containsKey(CredentialIssuerEntityConfigurationMetadataDecorator.VC_ISSUER));
+ }
+
+ @Test
+ public void testEntityTypeAlreadyThereIsAddedTo() throws Exception {
+ final Map<String, Map<String, Object>> metadata = new LinkedHashMap<>();
+ final Map<String, Object> existing = new LinkedHashMap<>();
+ existing.put("something_of_the_deployer", "kept");
+ metadata.put(CredentialIssuerEntityConfigurationMetadataDecorator.OPENID_CREDENTIAL_ISSUER, existing);
+
+ decoratorOf(true).accept(metadata, profileRequestCtx);
+
+ final Map<String, Object> stated =
+ metadata.get(CredentialIssuerEntityConfigurationMetadataDecorator.OPENID_CREDENTIAL_ISSUER);
+ Assert.assertEquals(stated.get("something_of_the_deployer"), "kept");
+ Assert.assertEquals(stated.get(CredentialIssuerMetadata.CREDENTIAL_ISSUER), "https://example.org");
+ }
+
+ @Test
+ public void testNullArguments() throws Exception {
+ final CredentialIssuerEntityConfigurationMetadataDecorator decorator = decoratorOf(true);
+
+ Assert.assertThrows(ConstraintViolationException.class,
+ () -> decorator.accept(null, profileRequestCtx));
+ Assert.assertThrows(ConstraintViolationException.class,
+ () -> decorator.accept(new LinkedHashMap<>(), null));
+ }
+
+ @Test
+ public void testNothingIsReadFromTheRequest() throws Exception {
+ final Map<String, Map<String, Object>> metadata = new LinkedHashMap<>();
+
+ // An entity configuration describes the entity, and the module caches one document
+ // per deployment, so a context carrying nothing must produce the whole statement.
+ decoratorOf(true).accept(metadata, new ProfileRequestContext());
+
+ Assert.assertEquals(metadata.keySet(),
+ Set.of(CredentialIssuerEntityConfigurationMetadataDecorator.OPENID_CREDENTIAL_ISSUER,
+ CredentialIssuerEntityConfigurationMetadataDecorator.VC_ISSUER,
+ CredentialIssuerEntityConfigurationMetadataDecorator.FEDERATION_ENTITY));
+ }
+
+ @Test
+ public void testNoCredentialSigningCredentials() throws Exception {
+ final CredentialIssuerEntityConfigurationMetadataDecorator decorator =
+ new CredentialIssuerEntityConfigurationMetadataDecorator();
+ decorator.setId("test");
+ decorator.setMetadataResolver(metadataResolver());
+ decorator.setCredentialSigningCredentials(List.of());
+
+ // Publishing in a federation with no key stated is refused at startup.
+ Assert.assertThrows(ComponentInitializationException.class, () -> decorator.initialize());
+ }
+
+ @Test
+ public void testNoMetadataResolver() {
+ final CredentialIssuerEntityConfigurationMetadataDecorator decorator =
+ new CredentialIssuerEntityConfigurationMetadataDecorator();
+ decorator.setId("test");
+
+ Assert.assertThrows(ComponentInitializationException.class, () -> decorator.initialize());
+ }
+
+}
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormOutboundIssuerMetadataResponseMessageTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormOutboundIssuerMetadataResponseMessageTest.java
new file mode 100644
index 0000000..0557811
--- /dev/null
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormOutboundIssuerMetadataResponseMessageTest.java
@@ -0,0 +1,163 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * 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 org.geant.shibboleth.plugin.openidvci.profile.impl;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.geant.shibboleth.plugin.openidvci.messaging.impl.CredentialIssuerMetadataSuccessResponse;
+import org.geant.shibboleth.plugin.openidvci.metadata.CredentialIssuerMetadata;
+import org.geant.shibboleth.plugin.openidvci.metadata.resolver.CredentialIssuerMetadataResolver;
+import org.opensaml.messaging.context.MessageContext;
+import org.opensaml.profile.action.EventIds;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.testng.Assert;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import net.shibboleth.idp.profile.context.navigate.WebflowRequestContextProfileRequestContextLookup;
+import net.shibboleth.idp.profile.testing.ActionTestingSupport;
+import net.shibboleth.idp.profile.testing.RequestContextBuilder;
+import net.shibboleth.shared.resolver.ResolverException;
+import org.springframework.webflow.execution.RequestContext;
+
+/**
+ * Unit tests for {@link FormOutboundIssuerMetadataResponseMessage}.
+ */
+public class FormOutboundIssuerMetadataResponseMessageTest {
+
+ private ProfileRequestContext profileRequestCtx;
+
+ private RequestContext requestCtx;
+
+ @BeforeMethod
+ protected void setUp() throws Exception {
+ requestCtx = new RequestContextBuilder().buildRequestContext();
+ profileRequestCtx = new WebflowRequestContextProfileRequestContextLookup().apply(requestCtx);
+ profileRequestCtx.setOutboundMessageContext(new MessageContext());
+ }
+
+ private Map<String, Object> members() {
+ final Map<String, Object> members = new LinkedHashMap<>();
+ members.put(CredentialIssuerMetadata.CREDENTIAL_ISSUER, "https://example.org");
+ members.put(CredentialIssuerMetadata.CREDENTIAL_ENDPOINT, "https://example.org/credentials");
+ members.put(CredentialIssuerMetadata.CREDENTIAL_CONFIGURATIONS_SUPPORTED,
+ Map.of("Diploma", Map.of("format", "dc+sd-jwt")));
+ members.put("display", List.of(Map.of("name", "Example")));
+ return members;
+ }
+
+ private FormOutboundIssuerMetadataResponseMessage actionOf(final CredentialIssuerMetadata metadata)
+ throws Exception {
+ final FormOutboundIssuerMetadataResponseMessage action = new FormOutboundIssuerMetadataResponseMessage();
+ action.setMetadataResolver(new StubResolver(metadata, false));
+ action.initialize();
+ return action;
+ }
+
+ @Test
+ public void testSuccess() throws Exception {
+ final FormOutboundIssuerMetadataResponseMessage action = actionOf(CredentialIssuerMetadata.parse(members()));
+
+ ActionTestingSupport.assertProceedEvent(action.execute(requestCtx));
+
+ final Object message = profileRequestCtx.ensureOutboundMessageContext().getMessage();
+ Assert.assertTrue(message instanceof CredentialIssuerMetadataSuccessResponse);
+
+ final Map<String, Object> published = new ObjectMapper().readValue(
+ ((CredentialIssuerMetadataSuccessResponse) message).toHTTPResponse().getContent(),
+ new TypeReference<LinkedHashMap<String, Object>>() {
+ });
+ Assert.assertEquals(published.keySet(), members().keySet());
+ Assert.assertEquals(published.get(CredentialIssuerMetadata.CREDENTIAL_ISSUER), "https://example.org");
+ }
+
+ @Test
+ public void testNoMetadata() throws Exception {
+ ActionTestingSupport.assertEvent(actionOf(null).execute(requestCtx), EventIds.IO_ERROR);
+ }
+
+ @Test
+ public void testResolverFails() throws Exception {
+ final FormOutboundIssuerMetadataResponseMessage action = new FormOutboundIssuerMetadataResponseMessage();
+ action.setMetadataResolver(new StubResolver(null, true));
+ action.initialize();
+
+ ActionTestingSupport.assertEvent(action.execute(requestCtx), EventIds.IO_ERROR);
+ }
+
+ @Test
+ public void testNoCredentialEndpoint() throws Exception {
+ final Map<String, Object> members = members();
+ members.remove(CredentialIssuerMetadata.CREDENTIAL_ENDPOINT);
+
+ ActionTestingSupport.assertEvent(actionOf(CredentialIssuerMetadata.parse(members)).execute(requestCtx),
+ EventIds.IO_ERROR);
+ }
+
+ @Test
+ public void testNoCredentialConfigurations() throws Exception {
+ final Map<String, Object> members = members();
+ members.remove(CredentialIssuerMetadata.CREDENTIAL_CONFIGURATIONS_SUPPORTED);
+
+ ActionTestingSupport.assertEvent(actionOf(CredentialIssuerMetadata.parse(members)).execute(requestCtx),
+ EventIds.IO_ERROR);
+ }
+
+ private static class StubResolver implements CredentialIssuerMetadataResolver {
+
+ private final CredentialIssuerMetadata metadata;
+
+ private final boolean fail;
+
+ StubResolver(final CredentialIssuerMetadata document, final boolean failing) {
+ metadata = document;
+ fail = failing;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public Iterable<CredentialIssuerMetadata> resolve(final ProfileRequestContext criteria)
+ throws ResolverException {
+ if (fail) {
+ throw new ResolverException("Testing");
+ }
+ return metadata == null ? List.of() : List.of(metadata);
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public CredentialIssuerMetadata resolveSingle(final ProfileRequestContext criteria) throws ResolverException {
+ if (fail) {
+ throw new ResolverException("Testing");
+ }
+ return metadata;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public String getId() {
+ return "stub";
+ }
+
+ }
+
+}
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ValidateCredentialOfferingTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ValidateCredentialOfferingTest.java
index 6e9c066..b19195a 100644
--- a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ValidateCredentialOfferingTest.java
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ValidateCredentialOfferingTest.java
@@ -16,8 +16,13 @@
package org.geant.shibboleth.plugin.openidvci.profile.impl;
+import java.io.IOException;
import java.net.URI;
+import java.util.List;
+import org.geant.shibboleth.plugin.openidvci.credential.CredentialConfigurations;
+import org.geant.shibboleth.plugin.openidvci.credential.impl.FilesystemCredentialConfigurationsResolver;
+import org.geant.shibboleth.plugin.openidvci.credential.resolver.CredentialConfigurationsResolver;
import org.geant.shibboleth.plugin.openidvci.messaging.context.CredentialOfferContext;
import org.geant.shibboleth.plugin.openidvci.messaging.impl.CredentialOfferRequest;
import org.geant.shibboleth.plugin.openidvci.profile.OpenIDVCIEventIds;
@@ -34,7 +39,6 @@ import com.nimbusds.oauth2.sdk.http.HTTPRequest;
import net.shibboleth.idp.profile.testing.ActionTestingSupport;
import net.shibboleth.idp.profile.testing.RequestContextBuilder;
import net.shibboleth.shared.component.ComponentInitializationException;
-import net.shibboleth.shared.spring.resource.ResourceHelper;
import net.shibboleth.idp.profile.context.navigate.WebflowRequestContextProfileRequestContextLookup;
/**
@@ -59,7 +63,7 @@ public class ValidateCredentialOfferingTest {
requestCtx = new RequestContextBuilder().buildRequestContext();
profileRequestCtx = new WebflowRequestContextProfileRequestContextLookup().apply(requestCtx);
action = new ValidateCredentialOffering();
- action.setCredentialsResource(ResourceHelper.of(new ClassPathResource("conf/verifiable-credentials.json")));
+ action.setCredentialConfigurationsResolver(resolverOf("conf/verifiable-credentials.json"));
action.initialize();
profileRequestCtx.setInboundMessageContext(new MessageContext());
profileRequestCtx.getInboundMessageContext().setMessage(CredentialOfferRequest.parse(httpRequest));
@@ -88,19 +92,30 @@ public class ValidateCredentialOfferingTest {
ActionTestingSupport.assertEvent(action.execute(requestCtx), OpenIDVCIEventIds.NO_CREDENTIAL_REQUEST);
}
+ private CredentialConfigurationsResolver resolverOf(final String path) throws Exception {
+ final FilesystemCredentialConfigurationsResolver resolver = new FilesystemCredentialConfigurationsResolver(
+ new ClassPathResource(path));
+ resolver.setId("test");
+ resolver.initialize();
+ return resolver;
+ }
+
@Test
- public void testUnparsableConfiguration() throws ComponentInitializationException {
- action = new ValidateCredentialOffering();
- action.setCredentialsResource(ResourceHelper.of(new ClassPathResource("conf/unparsable.txt")));
- action.initialize();
- ActionTestingSupport.assertEvent(action.execute(requestCtx), OpenIDVCIEventIds.NO_CREDENTIAL_CONFIGURATION);
+ public void testUnparsableConfiguration() {
+ org.testng.Assert.assertThrows(ComponentInitializationException.class, () -> resolverOf("conf/unparsable.txt"));
+ }
+
+ @Test
+ public void testNoConfigurationFile() {
+ org.testng.Assert.assertThrows(IOException.class, () -> resolverOf("conf/no.txt"));
}
- @Test(expectedExceptions = AssertionError.class)
- public void testNoConfiguration() throws ComponentInitializationException {
+ @Test
+ public void testNoConfigurationResolved() throws Exception {
action = new ValidateCredentialOffering();
- action.setCredentialsResource(ResourceHelper.of(new ClassPathResource("conf/no.txt")));
+ action.setCredentialConfigurationsResolver(new EmptyResolver());
action.initialize();
+
ActionTestingSupport.assertEvent(action.execute(requestCtx), OpenIDVCIEventIds.NO_CREDENTIAL_CONFIGURATION);
}
@@ -171,4 +186,27 @@ public class ValidateCredentialOfferingTest {
Assert.notNull(ctx.getValidatedCodeCredentials(), "Must not be null");
}
-}
\ No newline at end of file
+ /** Resolver that has nothing to resolve. */
+ private static class EmptyResolver implements CredentialConfigurationsResolver {
+
+ /** {@inheritDoc} */
+ @Override
+ public Iterable<CredentialConfigurations> resolve(final ProfileRequestContext criteria) {
+ return List.of();
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public CredentialConfigurations resolveSingle(final ProfileRequestContext criteria) {
+ return null;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public String getId() {
+ return "empty";
+ }
+
+ }
+
+}
diff --git a/openid-vci-impl/src/test/resources/conf/no-credential-configurations.json b/openid-vci-impl/src/test/resources/conf/no-credential-configurations.json
new file mode 100644
index 0000000..4ce0ecf
--- /dev/null
+++ b/openid-vci-impl/src/test/resources/conf/no-credential-configurations.json
@@ -0,0 +1,3 @@
+{
+ "something_else":{}
+}
diff --git a/openid-vci-impl/src/test/resources/conf/unparsable-credential-configuration.json b/openid-vci-impl/src/test/resources/conf/unparsable-credential-configuration.json
new file mode 100644
index 0000000..44d8d7c
--- /dev/null
+++ b/openid-vci-impl/src/test/resources/conf/unparsable-credential-configuration.json
@@ -0,0 +1,8 @@
+{
+ "credential_configurations_supported":{
+ "Broken":{
+ "format":"dc+sd-jwt",
+ "proof_types_supported":"this must be an object"
+ }
+ }
+}
diff --git a/openid-vci-impl/src/test/resources/credentials/sealer.jks b/openid-vci-impl/src/test/resources/credentials/sealer.jks
deleted file mode 100644
index 5ab65e2..0000000
Binary files a/openid-vci-impl/src/test/resources/credentials/sealer.jks and /dev/null differ
diff --git a/openid-vci-impl/src/test/resources/credentials/sealer.kver b/openid-vci-impl/src/test/resources/credentials/sealer.kver
deleted file mode 100644
index 8a34ad7..0000000
--- a/openid-vci-impl/src/test/resources/credentials/sealer.kver
+++ /dev/null
@@ -1 +0,0 @@
-CurrentVersion=1
diff --git a/openid-vci-impl/src/test/resources/metadata/openid-credential-issuer-no-issuer.json b/openid-vci-impl/src/test/resources/metadata/openid-credential-issuer-no-issuer.json
new file mode 100644
index 0000000..cd8c7b3
--- /dev/null
+++ b/openid-vci-impl/src/test/resources/metadata/openid-credential-issuer-no-issuer.json
@@ -0,0 +1,3 @@
+{
+ "credential_endpoint":"https://example.org/idp/profile/openid/vci/credentials"
+}
diff --git a/openid-vci-impl/src/test/resources/metadata/openid-credential-issuer-template.json b/openid-vci-impl/src/test/resources/metadata/openid-credential-issuer-template.json
new file mode 100644
index 0000000..12d7144
--- /dev/null
+++ b/openid-vci-impl/src/test/resources/metadata/openid-credential-issuer-template.json
@@ -0,0 +1,14 @@
+{
+ "credential_issuer":"$baseUrl",
+ "authorization_servers":[
+ "$issuer"
+ ],
+ "credential_endpoint":"$baseUrl/idp/profile/openid/vci/credentials",
+ "nonce_endpoint":"$baseUrl/idp/profile/openid/vci/nonce",
+ "display":[
+ {
+ "name":"$host",
+ "locale":"en-US"
+ }
+ ]
+}
diff --git a/openid-vci-impl/src/test/resources/metadata/openid-credential-issuer-unresolved.json b/openid-vci-impl/src/test/resources/metadata/openid-credential-issuer-unresolved.json
new file mode 100644
index 0000000..8ff6a40
--- /dev/null
+++ b/openid-vci-impl/src/test/resources/metadata/openid-credential-issuer-unresolved.json
@@ -0,0 +1,4 @@
+{
+ "credential_issuer":"$baseUrl",
+ "credential_endpoint":"$notAVariable/credentials"
+}
diff --git a/openid-vci-impl/src/test/resources/metadata/openid-credential-issuer.json b/openid-vci-impl/src/test/resources/metadata/openid-credential-issuer.json
new file mode 100644
index 0000000..e94b39e
--- /dev/null
+++ b/openid-vci-impl/src/test/resources/metadata/openid-credential-issuer.json
@@ -0,0 +1,14 @@
+{
+ "credential_issuer":"https://example.org",
+ "authorization_servers":[
+ "https://example.org"
+ ],
+ "credential_endpoint":"https://example.org/idp/profile/openid/vci/credentials",
+ "nonce_endpoint":"https://example.org/idp/profile/openid/vci/nonce",
+ "display":[
+ {
+ "name":"Example Credential Issuer",
+ "locale":"en-US"
+ }
+ ]
+}
diff --git a/pom.xml b/pom.xml
index 94d3eec..7a42b0d 100644
--- a/pom.xml
+++ b/pom.xml
@@ -18,6 +18,9 @@
<properties>
<shibboleth.projectName>java-idp-plugin-vci</shibboleth.projectName>
<oidc.op.version>4.3.0</oidc.op.version>
+ <!-- Compile only, for the OpenID Federation decorator. Unreleased, see openid-vci-oidfed.xml. -->
+ <oidfed-common.groupId>net.shibboleth.oidfed</oidfed-common.groupId>
+ <oidfed-common.version>1.0.0-SNAPSHOT</oidfed-common.version>
<shib-shared.groupId>net.shibboleth</shib-shared.groupId>
<shib-shared.version>9.0.0</shib-shared.version>
<oidc-common.groupId>net.shibboleth.oidc</oidc-common.groupId>
--
To stop receiving notification emails like this one, please contact
the administrator of this repository.
More information about the commits
mailing list