[java-idp-plugin-vci] branch main updated: Review jwt-vc-issuer flow: intercept, counter, headers, metadata validation, tests
Codeberg
noreply at shibboleth.net
Wed Oct 28 12:51:14 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/5b014fa5b5e662c9a446495327e14f2b1065999f
The following commit(s) were added to refs/heads/main by this push:
new 5b014fa Review jwt-vc-issuer flow: intercept, counter, headers, metadata validation, tests
5b014fa is described below
commit 5b014fa5b5e662c9a446495327e14f2b1065999f
Author: Janne Lauros <janne.lauros at csc.fi>
AuthorDate: Wed Oct 28 14:50:55 2026 +0200
Review jwt-vc-issuer flow: intercept, counter, headers, metadata validation, tests
---
README.md | 15 +-
.../openidvci/metadata/MetadataDocument.java | 65 ++++---
.../openidvci/metadata/MetadataDocumentTest.java | 129 +++++++++++++
.../impl/MetadataDocumentSuccessResponse.java | 65 -------
.../AbstractTemplatedFileMetadataResolver.java | 19 +-
.../FilesystemJWTVCIssuerMetadataResolver.java | 129 +++++++++++++
.../impl/DefaultMetadataDocumentConfiguration.java | 11 +-
.../FormOutboundDidDocumentResponseMessage.java | 14 +-
...ormOutboundMetadataDocumentResponseMessage.java | 64 ++++--
.../META-INF/net.shibboleth.idp/postconfig.xml | 12 +-
.../authorization-server-beans.xml | 3 +-
.../oauth/jwt-vc-issuer/jwt-vc-issuer-beans.xml | 16 +-
.../oauth/jwt-vc-issuer/jwt-vc-issuer-flow.xml | 6 +-
.../idp/service/relying-party/postconfig.xml | 15 +-
.../plugin/openidvci/conf/openid-vci.properties | 6 +-
.../FilesystemJWTVCIssuerMetadataResolverTest.java | 99 ++++++++++
.../FilesystemMetadataDocumentResolverTest.java | 144 ++++++++++++++
.../DefaultMetadataDocumentConfigurationTest.java | 65 +++++++
...utboundMetadataDocumentResponseMessageTest.java | 215 +++++++++++++++++++++
19 files changed, 918 insertions(+), 174 deletions(-)
diff --git a/README.md b/README.md
index 041ee27..feb2030 100644
--- a/README.md
+++ b/README.md
@@ -4440,8 +4440,10 @@ Endpoint `/idp/profile/openid/vci/issuer-metadata`, profile ID `http://geant.org
| `issuerMetadataSignatureSigningConfiguration` | SignatureSigningConfiguration | **openidvci.issuerMetadata.SigningConfiguration** | Signing of the metadata. Published unsigned until **openidvci.issuerMetadata.SigningCredentials** exists. Signed metadata is served for `Accept: application/jwt`. |
| `outboundInterceptorFlows` | List<String> | | Interceptor flows run before the response is sent, also for an error response. |
-**OAuth.JWTVCIssuer** (`/idp/profile/oauth/jwt-vc-issuer`) and **OAuth.AuthorizationServer**
-(`/idp/profile/oauth/authorization-server`) have one setting, `outboundInterceptorFlows`, as above.
+**OAuth.JWTVCIssuer** (`/idp/profile/oauth/jwt-vc-issuer`) has two settings,
+`inboundInterceptorFlows`, interceptor flows run before the document is formed, and
+`outboundInterceptorFlows` as above. **OAuth.AuthorizationServer**
+(`/idp/profile/oauth/authorization-server`) has one setting, `outboundInterceptorFlows`.
#### DID.Document
@@ -4617,7 +4619,7 @@ Members of a `vc+sd-jwt` credential, [W3C VCDM 2.0](https://www.w3.org/TR/vc-dat
|---|---|---|---|---|
| empty or `did:jwk`, the default | did:jwk of the signing key | the credential | nothing | `~did-jwk:<key thumbprint>` |
| `did:web` | did:web of this deployment, from the host of `idp.oidc.issuer` | `/.well-known/did.json`, header `kid` `did:web:…#<key name>` | **DID.Document** | `~did-web` |
-| `url` or the url itself | `idp.oidc.issuer` | `x5c` when the key has certificates, otherwise `/.well-known/jwt-vc-issuer` | **OAuth.JWTVCIssuer** and the key in **shibboleth.oidc.SigningCredentialsToPublish**, when no `x5c` | `~url` |
+| `url` or the url itself | `idp.oidc.issuer`, or the url | `x5c` when the key has certificates, otherwise `/.well-known/jwt-vc-issuer` | **OAuth.JWTVCIssuer** and the key in **shibboleth.oidc.SigningCredentialsToPublish**, when no `x5c` | `~url` |
The Status List Token of a credential has the same issuer as the credential.
@@ -4719,6 +4721,7 @@ The file comes with the plugin and is loaded automatically.
| `openidvci.jwtVcIssuer.templated` | Boolean | `true` | Whether the file is Velocity. |
| `openidvci.jwtVcIssuer.resolver` | Bean ID | **openidvci.DefaultJWTVCIssuerMetadataResolver** | Resolver of the document. |
| `openidvci.jwtVcIssuer.minRefreshDelay`, `…maxRefreshDelay` | Duration | `PT5M`, `PT4H` | Bounds of re-reading the file. |
+| `openidvci.jwtVcIssuer.cacheControl` | String | `max-age=3600` | Cache-Control of the response. |
| `openidvci.authorizationServer.template` | File pathname | `%{idp.home}/static/oauth-authorization-server.json` | Authorization server metadata template. |
| `openidvci.authorizationServer.templated` | Boolean | `true` | Whether the file is Velocity. |
| `openidvci.authorizationServer.resolver` | Bean ID | **openidvci.DefaultAuthorizationServerMetadataResolver** | Resolver of the document. |
@@ -4791,6 +4794,12 @@ The file comes with the plugin and is loaded automatically.
The templates are Velocity, `$issuer`, `$baseUrl` and `$host` are filled in from
`idp.oidc.issuer` of *conf/oidc.properties*.
+When `idp.oidc.issuer` has a path, the JWT VC issuer metadata is at `/.well-known/jwt-vc-issuer`
+followed by that path, `https://issuer.example.org/tenant` has it at
+`/.well-known/jwt-vc-issuer/tenant`, and the rewrite pattern is that.
+The JWT VC issuer metadata names one issuer. A credential with a url other than
+`idp.oidc.issuer` as its issuer is verified by `x5c`, or needs the `issuer` of the template
+changed to that url.
`credential_configurations_supported` is added from *metadata/verifiable-credentials.json*. The
Credential Issuer metadata endpoint answers `500 server_error` when `credential_endpoint` or
`credential_configurations_supported` is missing. `batch_credential_issuance.batch_size` is the
diff --git a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/MetadataDocument.java b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/MetadataDocument.java
index 8e3ce4e..e39018b 100644
--- a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/MetadataDocument.java
+++ b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/MetadataDocument.java
@@ -16,6 +16,8 @@
package org.geant.shibboleth.plugin.openidvci.metadata;
+import java.net.URI;
+import java.net.URISyntaxException;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
@@ -28,10 +30,6 @@ import com.nimbusds.oauth2.sdk.id.Issuer;
/**
* A metadata document this deployment publishes.
- *
- * Members are carried as they were parsed, this is the document that gets
- * published. Only 'issuer' is parsed, it names the document. Everything else is
- * the deployer's to write.
*/
public final class MetadataDocument {
@@ -78,33 +76,12 @@ public final class MetadataDocument {
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 an issuer
+ * @throws ParseException if the members do not name a valid issuer
*/
@Nonnull
public static MetadataDocument parse(@Nullable final Map<String, Object> members) throws ParseException {
@@ -113,12 +90,40 @@ public final class MetadataDocument {
throw new ParseException("Metadata document cannot be null");
}
- final Object issuer = members.get(ISSUER);
- if (!(issuer instanceof String) || ((String) issuer).isEmpty()) {
+ if (!(members.get(ISSUER) instanceof String value) || value.isEmpty()) {
throw new ParseException("Metadata document must have a non empty '" + ISSUER + "' member");
}
- return new MetadataDocument(Collections.unmodifiableMap(new LinkedHashMap<>(members)),
- new Issuer((String) issuer));
+ validateIssuer(value);
+ return new MetadataDocument(Collections.unmodifiableMap(new LinkedHashMap<>(members)), new Issuer(value));
+ }
+
+ /**
+ * Check the issuer is an https url with a host, no query, no fragment and no dot-segments.
+ *
+ * @param value issuer
+ * @throws ParseException if the issuer is not such a url
+ */
+ private static void validateIssuer(@Nonnull final String value) throws ParseException {
+
+ final URI uri;
+ try {
+ uri = new URI(value);
+ } catch (final URISyntaxException e) {
+ throw new ParseException("Issuer '" + value + "' is not a url", e);
+ }
+ if (!"https".equals(uri.getScheme()) || uri.getHost() == null) {
+ throw new ParseException("Issuer '" + value + "' is not an https url with a host");
+ }
+ if (uri.getRawQuery() != null || uri.getRawFragment() != null) {
+ throw new ParseException("Issuer '" + value + "' has a query or a fragment");
+ }
+ if (uri.getRawPath() != null) {
+ for (final String segment : uri.getRawPath().split("/", -1)) {
+ if (".".equals(segment) || "..".equals(segment)) {
+ throw new ParseException("Issuer '" + value + "' has a dot-segment in its path");
+ }
+ }
+ }
}
}
diff --git a/openid-vci-api/src/test/java/org/geant/shibboleth/plugin/openidvci/metadata/MetadataDocumentTest.java b/openid-vci-api/src/test/java/org/geant/shibboleth/plugin/openidvci/metadata/MetadataDocumentTest.java
new file mode 100644
index 0000000..0ab026c
--- /dev/null
+++ b/openid-vci-api/src/test/java/org/geant/shibboleth/plugin/openidvci/metadata/MetadataDocumentTest.java
@@ -0,0 +1,129 @@
+/*
+ * 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.DataProvider;
+import org.testng.annotations.Test;
+
+import com.nimbusds.oauth2.sdk.ParseException;
+
+/**
+ * Unit tests for {@link MetadataDocument}.
+ */
+public class MetadataDocumentTest {
+
+ private Map<String, Object> members(final Object issuer) {
+ final Map<String, Object> members = new LinkedHashMap<>();
+ members.put(MetadataDocument.ISSUER, issuer);
+ members.put("jwks_uri", "https://example.org/keyset");
+ members.put("display", List.of(Map.of("name", "Example")));
+ return members;
+ }
+
+ @DataProvider
+ public Object[][] validIssuers() {
+ return new Object[][] {
+ {"https://example.org"},
+ {"https://example.org/"},
+ {"https://example.org:8443"},
+ {"https://example.org/tenant/1234"},
+ {"https://example.org/a.b/.well"},
+ };
+ }
+
+ @DataProvider
+ public Object[][] invalidIssuers() {
+ return new Object[][] {
+ {""},
+ {"http://example.org"},
+ {"HTTPS://example.org"},
+ {"https:/example.org"},
+ {"example.org"},
+ {"https://example.org?tenant=1"},
+ {"https://example.org/?"},
+ {"https://example.org#top"},
+ {"https://example.org/#"},
+ {"https://example.org/./tenant"},
+ {"https://example.org/tenant/.."},
+ {"https://example.org/a/../b"},
+ {"https://exa mple.org"},
+ };
+ }
+
+ @Test(dataProvider = "validIssuers")
+ public void testValidIssuer(final String issuer) throws ParseException {
+ final MetadataDocument document = MetadataDocument.parse(members(issuer));
+
+ Assert.assertEquals(document.getIssuer().getValue(), issuer);
+ }
+
+ @Test(dataProvider = "invalidIssuers")
+ public void testInvalidIssuer(final String issuer) {
+ Assert.assertThrows(ParseException.class, () -> MetadataDocument.parse(members(issuer)));
+ }
+
+ @Test
+ public void testMemberOrderIsPreserved() throws ParseException {
+ final MetadataDocument document = MetadataDocument.parse(members("https://example.org"));
+
+ Assert.assertEquals(new ArrayList<>(document.getMembers().keySet()),
+ List.of(MetadataDocument.ISSUER, "jwks_uri", "display"));
+ }
+
+ @Test
+ public void testMembersAreNotModifiable() throws ParseException {
+ final MetadataDocument document = MetadataDocument.parse(members("https://example.org"));
+
+ Assert.assertThrows(UnsupportedOperationException.class, () -> document.getMembers().put("x", "y"));
+ }
+
+ @Test
+ public void testParsedDocumentIsDetachedFromInput() throws ParseException {
+ final Map<String, Object> input = members("https://example.org");
+ final MetadataDocument document = MetadataDocument.parse(input);
+
+ input.put("jwks", Map.of("keys", List.of()));
+
+ Assert.assertFalse(document.getMembers().containsKey("jwks"));
+ }
+
+ @Test
+ public void testNoMembers() {
+ Assert.assertThrows(ParseException.class, () -> MetadataDocument.parse(null));
+ }
+
+ @Test
+ public void testNoIssuer() {
+ final Map<String, Object> members = members("https://example.org");
+ members.remove(MetadataDocument.ISSUER);
+
+ Assert.assertThrows(ParseException.class, () -> MetadataDocument.parse(members));
+ }
+
+ @Test
+ public void testIssuerNotAString() {
+ Assert.assertThrows(ParseException.class,
+ () -> MetadataDocument.parse(members(List.of("https://example.org"))));
+ }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/impl/MetadataDocumentSuccessResponse.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/impl/MetadataDocumentSuccessResponse.java
deleted file mode 100644
index 24dab16..0000000
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/impl/MetadataDocumentSuccessResponse.java
+++ /dev/null
@@ -1,65 +0,0 @@
-/*
- * 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 a metadata document this deployment publishes.
- */
-public class MetadataDocumentSuccessResponse implements SuccessResponse {
-
- /** Serialized metadata document. */
- @Nonnull
- private final String content;
-
- /**
- * Constructor.
- *
- * @param document members of the metadata document
- *
- * @throws JsonProcessingException if the members cannot be serialized
- */
- public MetadataDocumentSuccessResponse(@Nonnull final Map<String, Object> document)
- throws JsonProcessingException {
- content = new ObjectMapper().writeValueAsString(document);
- }
-
- /** {@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/AbstractTemplatedFileMetadataResolver.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/AbstractTemplatedFileMetadataResolver.java
index 5f2a4eb..b1a4038 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/AbstractTemplatedFileMetadataResolver.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/AbstractTemplatedFileMetadataResolver.java
@@ -51,14 +51,9 @@ import net.shibboleth.shared.resolver.ResolverException;
import net.shibboleth.shared.velocity.Template;
/**
- * A metadata document read from a file, with the refresh behaviour of
+ * A metadata document read from a file, optionally a Velocity template, refreshed as
* {@link AbstractFileOIDCEntityResolver}.
*
- * 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 given to this resolver, and a deployer adds to them
- * or replaces them with a template context of their own.
- *
* @param <T> type of the document being read
*/
public abstract class AbstractTemplatedFileMetadataResolver<T>
@@ -118,9 +113,6 @@ public abstract class AbstractTemplatedFileMetadataResolver<T>
/**
* 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) {
@@ -131,9 +123,6 @@ public abstract class AbstractTemplatedFileMetadataResolver<T>
/**
* 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) {
@@ -142,10 +131,7 @@ public abstract class AbstractTemplatedFileMetadataResolver<T>
}
/**
- * Set the variables the deployer adds to the template.
- *
- * These are applied after the derived ones, so a deployer may also replace
- * those.
+ * Set the variables the deployer adds to, or replaces in, the template.
*
* @param context variables to add
*/
@@ -221,7 +207,6 @@ public abstract class AbstractTemplatedFileMetadataResolver<T>
} catch (final VelocityException e) {
throw new ParseException("Unable to evaluate the metadata document template: " + e.getMessage(), e);
} finally {
- // fromTemplate registers the template in a repository that outlives it.
if (compiled != null) {
StringResourceLoader.getRepository().removeStringResource(compiled.getTemplateName());
}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/FilesystemJWTVCIssuerMetadataResolver.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/FilesystemJWTVCIssuerMetadataResolver.java
new file mode 100644
index 0000000..1f6a809
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/FilesystemJWTVCIssuerMetadataResolver.java
@@ -0,0 +1,129 @@
+/*
+ * 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.util.Map;
+
+import javax.annotation.Nonnull;
+
+import org.geant.shibboleth.plugin.openidvci.metadata.MetadataDocument;
+import org.springframework.core.io.Resource;
+
+import com.nimbusds.jose.jwk.JWK;
+import com.nimbusds.jose.jwk.JWKSet;
+import com.nimbusds.oauth2.sdk.ParseException;
+
+/**
+ * JWT VC Issuer Metadata read from a file, as in <a href=
+ * "https://datatracker.ietf.org/doc/html/draft-ietf-oauth-sd-jwt-vc#name-jwt-vc-issuer-metadata-response">
+ * SD-JWT VC</a>.
+ */
+public class FilesystemJWTVCIssuerMetadataResolver extends FilesystemMetadataDocumentResolver {
+
+ /** Member carrying the JWK Set by value. */
+ @Nonnull
+ public static final String JWKS = "jwks";
+
+ /** Member referencing the JWK Set. */
+ @Nonnull
+ public static final String JWKS_URI = "jwks_uri";
+
+ /**
+ * Constructor.
+ *
+ * @param metadata the metadata file
+ *
+ * @throws IOException if the metadata cannot be loaded
+ */
+ public FilesystemJWTVCIssuerMetadataResolver(@Nonnull final Resource metadata) throws IOException {
+ super(metadata);
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ @Nonnull
+ protected MetadataDocument fromMembers(@Nonnull final Map<String, Object> members) throws ParseException {
+
+ final MetadataDocument document = super.fromMembers(members);
+ final Object jwks = document.getMembers().get(JWKS);
+ final Object jwksUri = document.getMembers().get(JWKS_URI);
+
+ if ((jwks == null) == (jwksUri == null)) {
+ throw new ParseException("JWT VC Issuer Metadata must have exactly one of '" + JWKS + "' and '"
+ + JWKS_URI + "'");
+ }
+ if (jwks != null) {
+ validateJwks(jwks);
+ } else {
+ validateJwksUri(jwksUri);
+ }
+ return document;
+ }
+
+ /**
+ * Check the value is a JWK Set of public keys.
+ *
+ * @param value value of {@value #JWKS}
+ * @throws ParseException if the value is not such a JWK Set
+ */
+ @SuppressWarnings("unchecked")
+ private static void validateJwks(@Nonnull final Object value) throws ParseException {
+
+ if (!(value instanceof Map)) {
+ throw new ParseException("'" + JWKS + "' is not a JSON object");
+ }
+ final JWKSet set;
+ try {
+ set = JWKSet.parse((Map<String, Object>) value);
+ } catch (final java.text.ParseException e) {
+ throw new ParseException("'" + JWKS + "' is not a JWK Set: " + e.getMessage(), e);
+ }
+ if (set.getKeys().isEmpty()) {
+ throw new ParseException("'" + JWKS + "' has no keys");
+ }
+ for (final JWK key : set.getKeys()) {
+ if (key.isPrivate()) {
+ throw new ParseException("'" + JWKS + "' has a private key");
+ }
+ }
+ }
+
+ /**
+ * Check the value is an https url with a host.
+ *
+ * @param value value of {@value #JWKS_URI}
+ * @throws ParseException if the value is not such a url
+ */
+ private static void validateJwksUri(@Nonnull final Object value) throws ParseException {
+
+ if (!(value instanceof String uri)) {
+ throw new ParseException("'" + JWKS_URI + "' is not a string");
+ }
+ try {
+ final URI parsed = new URI(uri);
+ if (!"https".equals(parsed.getScheme()) || parsed.getHost() == null) {
+ throw new ParseException("'" + JWKS_URI + "' " + uri + " is not an https url with a host");
+ }
+ } catch (final URISyntaxException e) {
+ throw new ParseException("'" + JWKS_URI + "' " + uri + " is not a url", e);
+ }
+ }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/config/impl/DefaultMetadataDocumentConfiguration.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/config/impl/DefaultMetadataDocumentConfiguration.java
index 0639be5..b768fc4 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/config/impl/DefaultMetadataDocumentConfiguration.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/config/impl/DefaultMetadataDocumentConfiguration.java
@@ -23,9 +23,6 @@ import net.shibboleth.shared.annotation.constraint.NotEmpty;
/**
* Profile configuration for an endpoint publishing a metadata document.
- *
- * There is nothing of the document to configure, so one class serves every such
- * endpoint and the profile is the identifier given to it.
*/
public class DefaultMetadataDocumentConfiguration extends AbstractInterceptorAwareProfileConfiguration {
@@ -34,10 +31,16 @@ public class DefaultMetadataDocumentConfiguration extends AbstractInterceptorAwa
@NotEmpty
public static final String PROFILE_ID_JWT_VC_ISSUER = "http://geant.org/ns/profiles/oauth/jwt-vc-issuer";
+ /** JWT VC issuer metadata profile counter name. */
+ @Nonnull
+ @NotEmpty
+ public static final String PROFILE_COUNTER_JWT_VC_ISSUER = "net.shibboleth.idp.profiles.oauth.jwt-vc-issuer";
+
/** ID for the authorization server metadata profile configuration. */
@Nonnull
@NotEmpty
- public static final String PROFILE_ID_AUTHORIZATION_SERVER = "http://geant.org/ns/profiles/oauth/authorization-server";
+ public static final String PROFILE_ID_AUTHORIZATION_SERVER =
+ "http://geant.org/ns/profiles/oauth/authorization-server";
/** ID for the did:web document profile configuration. */
@Nonnull
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormOutboundDidDocumentResponseMessage.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormOutboundDidDocumentResponseMessage.java
index 77a3476..383a702 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormOutboundDidDocumentResponseMessage.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormOutboundDidDocumentResponseMessage.java
@@ -27,7 +27,6 @@ import java.util.function.Function;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
-import org.geant.shibboleth.plugin.openidvci.messaging.impl.MetadataDocumentSuccessResponse;
import org.geant.shibboleth.plugin.openidvci.profile.config.navigate.DidDocumentSignatureSigningConfigurationLookupFunction;
import org.geant.shibboleth.plugin.openidvci.security.impl.DidSupport;
import org.opensaml.profile.action.ActionSupport;
@@ -36,10 +35,11 @@ import org.opensaml.profile.context.ProfileRequestContext;
import org.opensaml.security.credential.Credential;
import org.slf4j.Logger;
-import com.fasterxml.jackson.core.JsonProcessingException;
import com.nimbusds.jose.jwk.JWK;
+import net.minidev.json.JSONObject;
import net.shibboleth.idp.profile.AbstractProfileAction;
+import net.shibboleth.oidc.profile.messaging.JSONSuccessResponse;
import net.shibboleth.oidc.security.CredentialConversionUtil;
import net.shibboleth.oidc.security.credential.JWKCredential;
import net.shibboleth.oidc.security.jose.SignatureSigningConfiguration;
@@ -183,14 +183,8 @@ public class FormOutboundDidDocumentResponseMessage extends AbstractProfileActio
document.put(VERIFICATION_METHOD, methods);
document.put(ASSERTION_METHOD, assertions);
- try {
- profileRequestContext.ensureOutboundMessageContext()
- .setMessage(new MetadataDocumentSuccessResponse(document));
- } catch (final JsonProcessingException e) {
- log.error("{} Could not serialize the did:web document", getLogPrefix(), e);
- ActionSupport.buildEvent(profileRequestContext, EventIds.IO_ERROR);
- return;
- }
+ profileRequestContext.ensureOutboundMessageContext()
+ .setMessage(new JSONSuccessResponse(new JSONObject(document)));
log.debug("{} Publishing the did:web document of {}, verification methods {}", getLogPrefix(), didWeb, stated);
}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormOutboundMetadataDocumentResponseMessage.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormOutboundMetadataDocumentResponseMessage.java
index b33e05c..8a6d979 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormOutboundMetadataDocumentResponseMessage.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormOutboundMetadataDocumentResponseMessage.java
@@ -17,8 +17,10 @@
package org.geant.shibboleth.plugin.openidvci.profile.impl;
import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import jakarta.servlet.http.HttpServletResponse;
-import org.geant.shibboleth.plugin.openidvci.messaging.impl.MetadataDocumentSuccessResponse;
import org.geant.shibboleth.plugin.openidvci.metadata.MetadataDocument;
import org.geant.shibboleth.plugin.openidvci.metadata.resolver.MetadataDocumentResolver;
import org.opensaml.profile.action.ActionSupport;
@@ -26,22 +28,36 @@ import org.opensaml.profile.action.EventIds;
import org.opensaml.profile.context.ProfileRequestContext;
import org.slf4j.Logger;
-import com.fasterxml.jackson.core.JsonProcessingException;
+import com.google.common.net.HttpHeaders;
+import net.minidev.json.JSONObject;
import net.shibboleth.idp.profile.AbstractProfileAction;
+import net.shibboleth.idp.profile.IdPEventIds;
+import net.shibboleth.oidc.profile.messaging.JSONSuccessResponse;
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.primitive.LoggerFactory;
+import net.shibboleth.shared.primitive.StringSupport;
import net.shibboleth.shared.resolver.ResolverException;
/**
- * Action forming {@link MetadataDocumentSuccessResponse} from the document the
- * attached {@link MetadataDocumentResolver} resolves.
+ * Action forming {@link JSONSuccessResponse} from the document the attached
+ * {@link MetadataDocumentResolver} resolves.
*/
public class FormOutboundMetadataDocumentResponseMessage extends AbstractProfileAction {
+ /** Value of the X-Content-Type-Options header. */
+ @Nonnull
+ @NotEmpty
+ public static final String NOSNIFF = "nosniff";
+
+ /** Value of the Content-Security-Policy header. */
+ @Nonnull
+ @NotEmpty
+ public static final String CONTENT_SECURITY_POLICY = "default-src 'none'";
+
/** Class logger. */
@Nonnull
private final Logger log = LoggerFactory.getLogger(FormOutboundMetadataDocumentResponseMessage.class);
@@ -50,10 +66,20 @@ public class FormOutboundMetadataDocumentResponseMessage extends AbstractProfile
@NonnullAfterInit
private MetadataDocumentResolver metadataResolver;
+ /** Value of the Cache-Control header. */
+ @Nonnull
+ @NotEmpty
+ private String cacheControl;
+
/** Document to publish. */
- @NonnullBeforeExec
+ @Nullable
private MetadataDocument document;
+ /** Constructor. */
+ public FormOutboundMetadataDocumentResponseMessage() {
+ cacheControl = "max-age=3600";
+ }
+
/**
* Set the resolver of the document that is being published.
*
@@ -64,6 +90,16 @@ public class FormOutboundMetadataDocumentResponseMessage extends AbstractProfile
metadataResolver = Constraint.isNotNull(resolver, "The metadata resolver cannot be null");
}
+ /**
+ * Set the value of the Cache-Control header.
+ *
+ * @param value header value
+ */
+ public void setCacheControl(@Nonnull @NotEmpty final String value) {
+ checkSetterPreconditions();
+ cacheControl = Constraint.isNotNull(StringSupport.trimOrNull(value), "Cache-Control cannot be null or empty");
+ }
+
/** {@inheritDoc} */
@Override
protected void doInitialize() throws ComponentInitializationException {
@@ -104,14 +140,18 @@ public class FormOutboundMetadataDocumentResponseMessage extends AbstractProfile
assert document != null;
- try {
- profileRequestContext.ensureOutboundMessageContext()
- .setMessage(new MetadataDocumentSuccessResponse(document.getMembers()));
- } catch (final JsonProcessingException e) {
- log.error("{} Could not serialize the metadata document", getLogPrefix(), e);
- ActionSupport.buildEvent(profileRequestContext, EventIds.IO_ERROR);
+ final HttpServletResponse response = getHttpServletResponse();
+ if (response == null) {
+ log.error("{} Could not fetch HttpServletResponse", getLogPrefix());
+ ActionSupport.buildEvent(profileRequestContext, IdPEventIds.INVALID_PROFILE_CONFIG);
return;
}
+ response.setHeader(HttpHeaders.CACHE_CONTROL, cacheControl);
+ response.setHeader(HttpHeaders.X_CONTENT_TYPE_OPTIONS, NOSNIFF);
+ response.setHeader(HttpHeaders.CONTENT_SECURITY_POLICY, CONTENT_SECURITY_POLICY);
+
+ profileRequestContext.ensureOutboundMessageContext()
+ .setMessage(new JSONSuccessResponse(new JSONObject(document.getMembers())));
log.debug("{} Publishing the metadata document of {}, members {}", getLogPrefix(),
document.getIssuer(), document.getMembers().keySet());
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 b9ef989..a8c9d8b 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
@@ -154,12 +154,9 @@
<bean id="openidvci.CredentialsResource" class="org.springframework.core.io.FileSystemResource" lazy-init="true"
c:path="%{openidvci.credentialConfigurations.resource:%{idp.home}/metadata/verifiable-credentials.json}" />
- <!--
- JWT VC issuer metadata, published at /.well-known/jwt-vc-issuer. A Velocity template
- with the same $issuer, $baseUrl and $host variables as the document above.
- -->
+ <!-- JWT VC issuer metadata, published at /.well-known/jwt-vc-issuer. -->
<bean id="openidvci.DefaultJWTVCIssuerMetadataResolver" lazy-init="true"
- class="org.geant.shibboleth.plugin.openidvci.metadata.impl.FilesystemMetadataDocumentResolver"
+ class="org.geant.shibboleth.plugin.openidvci.metadata.impl.FilesystemJWTVCIssuerMetadataResolver"
depends-on="shibboleth.LoggingService"
p:id="openidvci.DefaultJWTVCIssuerMetadataResolver"
p:minRefreshDelay="%{openidvci.jwtVcIssuer.minRefreshDelay:PT5M}"
@@ -173,10 +170,7 @@
lazy-init="true"
c:path="%{openidvci.jwtVcIssuer.template:%{idp.home}/static/jwt-vc-issuer.json}" />
- <!--
- Authorization server metadata, published at /.well-known/oauth-authorization-server. A
- Velocity template as above, and everything in it is the deployer's to write.
- -->
+ <!-- Authorization server metadata, published at /.well-known/oauth-authorization-server. -->
<bean id="openidvci.DefaultAuthorizationServerMetadataResolver" lazy-init="true"
class="org.geant.shibboleth.plugin.openidvci.metadata.impl.FilesystemMetadataDocumentResolver"
depends-on="shibboleth.LoggingService"
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/authorization-server/authorization-server-beans.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/authorization-server/authorization-server-beans.xml
index a1a963d..2445724 100644
--- a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/authorization-server/authorization-server-beans.xml
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/authorization-server/authorization-server-beans.xml
@@ -21,7 +21,8 @@
<bean id="FormOutboundMessage"
class="org.geant.shibboleth.plugin.openidvci.profile.impl.FormOutboundMetadataDocumentResponseMessage"
scope="prototype"
- p:metadataResolver-ref="#{'%{openidvci.authorizationServer.resolver:openidvci.DefaultAuthorizationServerMetadataResolver}'.trim()}" />
+ p:metadataResolver-ref="#{'%{openidvci.authorizationServer.resolver:openidvci.DefaultAuthorizationServerMetadataResolver}'.trim()}"
+ p:httpServletResponseSupplier-ref="shibboleth.HttpServletResponseSupplier" />
<bean id="BuildErrorResponseFromEvent" class="net.shibboleth.oidc.profile.impl.BuildJSONErrorResponseFromEvent"
scope="prototype" p:defaultStatusCode="500" p:defaultCode="server_error"
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/jwt-vc-issuer/jwt-vc-issuer-beans.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/jwt-vc-issuer/jwt-vc-issuer-beans.xml
index 595644a..1499d35 100644
--- a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/jwt-vc-issuer/jwt-vc-issuer-beans.xml
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/jwt-vc-issuer/jwt-vc-issuer-beans.xml
@@ -11,17 +11,23 @@
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="openidvci.profileId" class="java.lang.String" c:_0="http://geant.org/ns/profiles/oauth/jwt-vc-issuer"/>
+ <bean id="openidvci.profileId" class="java.lang.String"
+ c:_0="#{T(org.geant.shibboleth.plugin.openidvci.profile.config.impl.DefaultMetadataDocumentConfiguration).PROFILE_ID_JWT_VC_ISSUER}"/>
<bean id="openidvci.loggingId" class="java.lang.String"
c:_0="%{openidvci.logging.jwt-vc-issuer:OAuth.JWTVCIssuer}"/>
+ <util:constant id="shibboleth.metrics.ProfileCounter"
+ static-field="org.geant.shibboleth.plugin.openidvci.profile.config.impl.DefaultMetadataDocumentConfiguration.PROFILE_COUNTER_JWT_VC_ISSUER"/>
+
<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.FormOutboundMetadataDocumentResponseMessage"
scope="prototype"
- p:metadataResolver-ref="#{'%{openidvci.jwtVcIssuer.resolver:openidvci.DefaultJWTVCIssuerMetadataResolver}'.trim()}" />
+ p:metadataResolver-ref="#{'%{openidvci.jwtVcIssuer.resolver:openidvci.DefaultJWTVCIssuerMetadataResolver}'.trim()}"
+ p:httpServletResponseSupplier-ref="shibboleth.HttpServletResponseSupplier"
+ p:cacheControl="%{openidvci.jwtVcIssuer.cacheControl:max-age=3600}" />
<bean id="BuildErrorResponseFromEvent" class="net.shibboleth.oidc.profile.impl.BuildJSONErrorResponseFromEvent"
scope="prototype" p:defaultStatusCode="500" p:defaultCode="server_error"
@@ -29,12 +35,6 @@
<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/oauth/jwt-vc-issuer/jwt-vc-issuer-flow.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/jwt-vc-issuer/jwt-vc-issuer-flow.xml
index e4bfe05..f18032c 100644
--- a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/jwt-vc-issuer/jwt-vc-issuer-flow.xml
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/jwt-vc-issuer/jwt-vc-issuer-flow.xml
@@ -5,6 +5,9 @@
parent="openid/vci/abstract-api">
<action-state id="InitializeMandatoryContexts">
+ <on-entry>
+ <set name="flowScope.transitionAfterInboundIntercept" value="'BuildResponseMessage'"/>
+ </on-entry>
<evaluate expression="InitializeProfileRequestContext" />
<evaluate expression="PopulateMetricContext" />
<evaluate expression="FlowStartPopulateAuditContext" />
@@ -12,9 +15,10 @@
<evaluate expression="InitializeUnverifiedRelyingPartyContext" />
<evaluate expression="SelectRelyingPartyConfiguration" />
<evaluate expression="SelectProfileConfiguration" />
+ <evaluate expression="PopulateInboundInterceptContext" />
<evaluate expression="'proceed'" />
- <transition on="proceed" to="BuildResponseMessage" />
+ <transition on="proceed" to="CheckInboundInterceptContext" />
</action-state>
<bean-import resource="classpath:/META-INF/net/shibboleth/idp/flows/oauth/jwt-vc-issuer/jwt-vc-issuer-beans.xml" />
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 f609604..d180ad0 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
@@ -52,19 +52,13 @@
p:issuedClaimsValidator="#{getObject('openidvci.DefaultAccessTokenClaimsValidator')}"
p:dpopProofClaimsValidator="#{getObject('openidvci.DefaultDPoPWithAthProofClaimsValidator')}" />
- <!--
- Endpoint publishing Credential Issuer metadata. Serves wallets before there is a
- client, so there is nothing of the VCI profile settings to inherit here.
- -->
+ <!-- Endpoint publishing Credential Issuer metadata. -->
<bean id="OpenID.VCI.IssuerMetadata" lazy-init="true"
class="org.geant.shibboleth.plugin.openidvci.profile.config.impl.DefaultOpenIDVCIIssuerMetadataConfiguration"
p:securityConfiguration-ref="%{idp.security.oidc.config:shibboleth.oidc.DefaultSecurityConfiguration}"
p:issuerMetadataSignatureSigningConfiguration-ref="openidvci.issuerMetadata.SigningConfiguration" />
- <!--
- Endpoints publishing a metadata document. They serve anyone, so there is nothing of the
- VCI profile settings to inherit and nothing of the document to configure here.
- -->
+ <!-- Endpoints publishing a metadata document. -->
<bean id="OAuth.JWTVCIssuer" lazy-init="true"
class="org.geant.shibboleth.plugin.openidvci.profile.config.impl.DefaultMetadataDocumentConfiguration"
c:id="#{T(org.geant.shibboleth.plugin.openidvci.profile.config.impl.DefaultMetadataDocumentConfiguration).PROFILE_ID_JWT_VC_ISSUER}"
@@ -75,10 +69,7 @@
c:id="#{T(org.geant.shibboleth.plugin.openidvci.profile.config.impl.DefaultMetadataDocumentConfiguration).PROFILE_ID_AUTHORIZATION_SERVER}"
p:securityConfiguration-ref="%{idp.security.oidc.config:shibboleth.oidc.DefaultSecurityConfiguration}" />
- <!--
- Endpoint publishing the did:web document of this deployment, the keys a verifier
- resolves a credential of a did:web issuer by. Serves anyone, as the two above do.
- -->
+ <!-- Endpoint publishing the did:web document of this deployment. -->
<bean id="DID.Document" lazy-init="true"
class="org.geant.shibboleth.plugin.openidvci.profile.config.impl.DefaultDidDocumentConfiguration"
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.properties b/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/conf/openid-vci.properties
index 7888d6f..fa7c9c6 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
@@ -83,9 +83,9 @@ openidvci.status-list.index.permutationKey = change_this_to_secret
#openidvci.status-list.index.StorageService = shibboleth.StorageService
# Settings for the jwt-vc-issuer flow
-# Flow is available at /openid/vci/../oauth/jwt-vc-issuer, usually it should be wired from
+# Flow is available at /oauth/jwt-vc-issuer, usually it should be wired from
# /.well-known/jwt-vc-issuer. A verifier reads that document to find the keys of the issuer, but
-# only when a credential names its issuer by url. The default Credential Issuer identifier is
+# only when a credential names its issuer by url and its key has no certificates. The default Credential Issuer identifier is
# did:jwk, which carries the key inside the credential, so nothing reads the document until you
# set openidvci.issuer.
#openidvci.jwtVcIssuer.template = %{idp.home}/static/jwt-vc-issuer.json
@@ -96,6 +96,8 @@ openidvci.status-list.index.permutationKey = change_this_to_secret
# Bounds on the next file refresh of the document
#openidvci.jwtVcIssuer.minRefreshDelay = PT5M
#openidvci.jwtVcIssuer.maxRefreshDelay = PT4H
+# Cache-Control of the response, so a verifier does not fetch the document on every credential
+#openidvci.jwtVcIssuer.cacheControl = max-age=3600
# Settings for the authorization-server flow
# Flow is available at /oauth/authorization-server, usually it should be wired from
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/FilesystemJWTVCIssuerMetadataResolverTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/FilesystemJWTVCIssuerMetadataResolverTest.java
new file mode 100644
index 0000000..e9138fb
--- /dev/null
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/FilesystemJWTVCIssuerMetadataResolverTest.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.metadata.impl;
+
+import org.geant.shibboleth.plugin.openidvci.metadata.MetadataDocument;
+import org.testng.Assert;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+
+import com.nimbusds.jose.jwk.Curve;
+import com.nimbusds.jose.jwk.ECKey;
+import com.nimbusds.jose.jwk.gen.ECKeyGenerator;
+
+import net.shibboleth.shared.component.ComponentInitializationException;
+
+/**
+ * Unit tests for {@link FilesystemJWTVCIssuerMetadataResolver}.
+ */
+public class FilesystemJWTVCIssuerMetadataResolverTest {
+
+ private ECKey key;
+
+ @BeforeClass
+ protected void setUp() throws Exception {
+ key = new ECKeyGenerator(Curve.P_256).keyID("issuer-key").generate();
+ }
+
+ private FilesystemJWTVCIssuerMetadataResolver resolverOf(final String content) throws Exception {
+ final FilesystemJWTVCIssuerMetadataResolver resolver = new FilesystemJWTVCIssuerMetadataResolver(
+ FilesystemMetadataDocumentResolverTest.fileOf(content));
+ resolver.setId("test");
+ resolver.initialize();
+ return resolver;
+ }
+
+ private String document(final String members) {
+ return "{\"issuer\":\"https://example.org\"" + members + "}";
+ }
+
+ private String jwks(final boolean privateKey) {
+ return "{\"keys\":[" + (privateKey ? key : key.toPublicJWK()).toJSONString() + "]}";
+ }
+
+ @Test
+ public void testJwksUri() throws Exception {
+ final MetadataDocument document = resolverOf(document(",\"jwks_uri\":\"https://example.org/keyset\""))
+ .resolveSingle(null);
+
+ Assert.assertNotNull(document);
+ Assert.assertEquals(document.getMembers().get(FilesystemJWTVCIssuerMetadataResolver.JWKS_URI),
+ "https://example.org/keyset");
+ }
+
+ @Test
+ public void testJwks() throws Exception {
+ final MetadataDocument document = resolverOf(document(",\"jwks\":" + jwks(false))).resolveSingle(null);
+
+ Assert.assertNotNull(document);
+ Assert.assertTrue(document.getMembers().containsKey(FilesystemJWTVCIssuerMetadataResolver.JWKS));
+ }
+
+ @DataProvider
+ public Object[][] invalidMembers() {
+ return new Object[][] {
+ {""},
+ {",\"jwks_uri\":\"https://example.org/keyset\",\"jwks\":" + jwks(false)},
+ {",\"jwks_uri\":\"http://example.org/keyset\""},
+ {",\"jwks_uri\":\"/idp/profile/oidc/keyset\""},
+ {",\"jwks_uri\":\"https://exa mple.org/keyset\""},
+ {",\"jwks_uri\":[\"https://example.org/keyset\"]"},
+ {",\"jwks\":\"https://example.org/keyset\""},
+ {",\"jwks\":{\"keys\":[]}"},
+ {",\"jwks\":{\"keys\":[{\"kty\":\"EC\"}]}"},
+ {",\"jwks\":{\"no-keys\":[]}"},
+ {",\"jwks\":" + jwks(true)},
+ };
+ }
+
+ @Test(dataProvider = "invalidMembers")
+ public void testInvalid(final String members) {
+ Assert.assertThrows(ComponentInitializationException.class, () -> resolverOf(document(members)));
+ }
+
+}
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/FilesystemMetadataDocumentResolverTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/FilesystemMetadataDocumentResolverTest.java
new file mode 100644
index 0000000..1ca3804
--- /dev/null
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/FilesystemMetadataDocumentResolverTest.java
@@ -0,0 +1,144 @@
+/*
+ * 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.File;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.util.Map;
+
+import org.apache.velocity.app.VelocityEngine;
+import org.apache.velocity.runtime.resource.loader.StringResourceLoader;
+import org.geant.shibboleth.plugin.openidvci.metadata.MetadataDocument;
+import org.springframework.core.io.FileSystemResource;
+import org.testng.Assert;
+import org.testng.annotations.Test;
+
+import net.shibboleth.shared.component.ComponentInitializationException;
+
+/**
+ * Unit tests for {@link FilesystemMetadataDocumentResolver}.
+ */
+public class FilesystemMetadataDocumentResolverTest {
+
+ private static final String TEMPLATE = "{\"issuer\":\"$issuer\",\"jwks_uri\":\"$baseUrl/idp/profile/oidc/keyset\","
+ + "\"host\":\"$host\"}";
+
+ static FileSystemResource fileOf(final String content) throws Exception {
+ final File file = Files.createTempFile("metadata-document", ".json").toFile();
+ file.deleteOnExit();
+ Files.writeString(file.toPath(), content, StandardCharsets.UTF_8);
+ return new FileSystemResource(file);
+ }
+
+ static 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 FilesystemMetadataDocumentResolver resolverOf(final String content, final boolean templated,
+ final String issuer, final Map<String, ?> context) throws Exception {
+ final FilesystemMetadataDocumentResolver resolver = new FilesystemMetadataDocumentResolver(fileOf(content));
+ resolver.setId("test");
+ if (templated) {
+ resolver.setVelocityEngine(velocityEngine());
+ resolver.setIssuer(issuer);
+ resolver.setTemplateContext(context);
+ }
+ resolver.initialize();
+ return resolver;
+ }
+
+ @Test
+ public void testFile() throws Exception {
+ final MetadataDocument document = resolverOf(
+ "{\"issuer\":\"https://example.org\",\"jwks_uri\":\"https://example.org/keyset\"}", false, null, null)
+ .resolveSingle(null);
+
+ Assert.assertNotNull(document);
+ Assert.assertEquals(document.getIssuer().getValue(), "https://example.org");
+ Assert.assertEquals(document.getMembers().get("jwks_uri"), "https://example.org/keyset");
+ }
+
+ @Test
+ public void testTemplate() throws Exception {
+ final MetadataDocument document = resolverOf(TEMPLATE, true, "https://example.org", null).resolveSingle(null);
+
+ Assert.assertNotNull(document);
+ Assert.assertEquals(document.getIssuer().getValue(), "https://example.org");
+ Assert.assertEquals(document.getMembers().get("jwks_uri"), "https://example.org/idp/profile/oidc/keyset");
+ Assert.assertEquals(document.getMembers().get("host"), "example.org");
+ }
+
+ @Test
+ public void testTemplateIssuerWithPathAndPort() throws Exception {
+ final MetadataDocument document = resolverOf(TEMPLATE, true, "https://example.org:8443/tenant", null)
+ .resolveSingle(null);
+
+ Assert.assertNotNull(document);
+ Assert.assertEquals(document.getIssuer().getValue(), "https://example.org:8443/tenant");
+ Assert.assertEquals(document.getMembers().get("jwks_uri"),
+ "https://example.org:8443/idp/profile/oidc/keyset");
+ Assert.assertEquals(document.getMembers().get("host"), "example.org");
+ }
+
+ @Test
+ public void testTemplateContextReplacesVariable() throws Exception {
+ final MetadataDocument document = resolverOf(TEMPLATE, true, "https://example.org",
+ Map.of("baseUrl", "https://keys.example.org")).resolveSingle(null);
+
+ Assert.assertNotNull(document);
+ Assert.assertEquals(document.getMembers().get("jwks_uri"), "https://keys.example.org/idp/profile/oidc/keyset");
+ }
+
+ @Test
+ public void testNotTemplated() throws Exception {
+ final MetadataDocument document = resolverOf(
+ "{\"issuer\":\"https://example.org\",\"note\":\"$issuer #if\"}", false, null, null).resolveSingle(null);
+
+ Assert.assertNotNull(document);
+ Assert.assertEquals(document.getMembers().get("note"), "$issuer #if");
+ }
+
+ @Test
+ public void testInvalidIssuer() {
+ Assert.assertThrows(ComponentInitializationException.class,
+ () -> resolverOf("{\"issuer\":\"http://example.org\"}", false, null, null));
+ }
+
+ @Test
+ public void testNoIssuer() {
+ Assert.assertThrows(ComponentInitializationException.class,
+ () -> resolverOf("{\"jwks_uri\":\"https://example.org/keyset\"}", false, null, null));
+ }
+
+ @Test
+ public void testNotAnObject() {
+ Assert.assertThrows(ComponentInitializationException.class,
+ () -> resolverOf("[\"https://example.org\"]", false, null, null));
+ }
+
+ @Test
+ public void testIssuerWithoutHost() {
+ Assert.assertThrows(ComponentInitializationException.class,
+ () -> resolverOf(TEMPLATE, true, "example.org", null));
+ }
+
+}
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/config/impl/DefaultMetadataDocumentConfigurationTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/config/impl/DefaultMetadataDocumentConfigurationTest.java
new file mode 100644
index 0000000..7fa7185
--- /dev/null
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/config/impl/DefaultMetadataDocumentConfigurationTest.java
@@ -0,0 +1,65 @@
+/*
+ * 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.config.impl;
+
+import java.util.List;
+import java.util.Set;
+
+import org.testng.Assert;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+
+/** Tests for {@link DefaultMetadataDocumentConfiguration}. */
+public class DefaultMetadataDocumentConfigurationTest {
+
+ @DataProvider
+ public Object[][] profileIds() {
+ return new Object[][] {
+ {DefaultMetadataDocumentConfiguration.PROFILE_ID_JWT_VC_ISSUER},
+ {DefaultMetadataDocumentConfiguration.PROFILE_ID_AUTHORIZATION_SERVER},
+ {DefaultMetadataDocumentConfiguration.PROFILE_ID_DID_DOCUMENT},
+ };
+ }
+
+ @Test(dataProvider = "profileIds")
+ public void testId(final String id) {
+ Assert.assertEquals(new DefaultMetadataDocumentConfiguration(id).getId(), id);
+ }
+
+ @Test
+ public void testIdsAreDistinct() {
+ Assert.assertEquals(Set.of(DefaultMetadataDocumentConfiguration.PROFILE_ID_JWT_VC_ISSUER,
+ DefaultMetadataDocumentConfiguration.PROFILE_ID_AUTHORIZATION_SERVER,
+ DefaultMetadataDocumentConfiguration.PROFILE_ID_DID_DOCUMENT).size(), 3);
+ }
+
+ @Test
+ public void testDidDocumentId() {
+ Assert.assertEquals(new DefaultDidDocumentConfiguration().getId(),
+ DefaultMetadataDocumentConfiguration.PROFILE_ID_DID_DOCUMENT);
+ }
+
+ @Test
+ public void testNoInterceptorFlowsByDefault() {
+ final DefaultMetadataDocumentConfiguration configuration =
+ new DefaultMetadataDocumentConfiguration(DefaultMetadataDocumentConfiguration.PROFILE_ID_JWT_VC_ISSUER);
+
+ Assert.assertEquals(configuration.getInboundInterceptorFlows(null), List.of());
+ Assert.assertEquals(configuration.getOutboundInterceptorFlows(null), List.of());
+ }
+
+}
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormOutboundMetadataDocumentResponseMessageTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormOutboundMetadataDocumentResponseMessageTest.java
new file mode 100644
index 0000000..6b230b7
--- /dev/null
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormOutboundMetadataDocumentResponseMessageTest.java
@@ -0,0 +1,215 @@
+/*
+ * 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.metadata.MetadataDocument;
+import org.geant.shibboleth.plugin.openidvci.metadata.resolver.MetadataDocumentResolver;
+import org.opensaml.messaging.context.MessageContext;
+import org.opensaml.profile.action.EventIds;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.webflow.execution.RequestContext;
+import org.testng.Assert;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+import com.nimbusds.common.contenttype.ContentType;
+import com.nimbusds.oauth2.sdk.http.HTTPResponse;
+
+import jakarta.servlet.http.HttpServletResponse;
+import net.minidev.json.JSONObject;
+import net.minidev.json.JSONValue;
+import net.shibboleth.idp.profile.IdPEventIds;
+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.oidc.profile.messaging.JSONSuccessResponse;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.logic.ConstraintViolationException;
+import net.shibboleth.shared.primitive.NonnullSupplier;
+import net.shibboleth.shared.resolver.ResolverException;
+
+/**
+ * Unit tests for {@link FormOutboundMetadataDocumentResponseMessage}.
+ */
+public class FormOutboundMetadataDocumentResponseMessageTest {
+
+ private ProfileRequestContext profileRequestCtx;
+
+ private RequestContext requestCtx;
+
+ private MockHttpServletResponse httpResponse;
+
+ @BeforeMethod
+ protected void setUp() throws Exception {
+ requestCtx = new RequestContextBuilder().buildRequestContext();
+ profileRequestCtx = new WebflowRequestContextProfileRequestContextLookup().apply(requestCtx);
+ profileRequestCtx.setOutboundMessageContext(new MessageContext());
+ httpResponse = new MockHttpServletResponse();
+ }
+
+ private Map<String, Object> members() {
+ final Map<String, Object> members = new LinkedHashMap<>();
+ members.put(MetadataDocument.ISSUER, "https://example.org");
+ members.put("jwks_uri", "https://example.org/idp/profile/oidc/keyset");
+ return members;
+ }
+
+ private FormOutboundMetadataDocumentResponseMessage actionOf(final MetadataDocument document,
+ final boolean failing, final boolean withResponse) throws Exception {
+ final FormOutboundMetadataDocumentResponseMessage action = new FormOutboundMetadataDocumentResponseMessage();
+ action.setMetadataResolver(new StubResolver(document, failing));
+ if (withResponse) {
+ action.setHttpServletResponseSupplier(new NonnullSupplier<>() {
+ public HttpServletResponse get() {
+ return httpResponse;
+ }
+ });
+ }
+ return action;
+ }
+
+ @Test
+ public void testSuccess() throws Exception {
+ final FormOutboundMetadataDocumentResponseMessage action =
+ actionOf(MetadataDocument.parse(members()), false, true);
+ action.initialize();
+
+ ActionTestingSupport.assertProceedEvent(action.execute(requestCtx));
+
+ final Object message = profileRequestCtx.ensureOutboundMessageContext().getMessage();
+ Assert.assertTrue(message instanceof JSONSuccessResponse);
+ final HTTPResponse response = ((JSONSuccessResponse) message).toHTTPResponse();
+ Assert.assertEquals(response.getStatusCode(), HTTPResponse.SC_OK);
+ Assert.assertEquals(response.getEntityContentType().getBaseType(), ContentType.APPLICATION_JSON.getBaseType());
+ final JSONObject published = (JSONObject) JSONValue.parse(response.getContent());
+ Assert.assertEquals(published, new JSONObject(members()));
+ }
+
+ @Test
+ public void testDefaultHeaders() throws Exception {
+ final FormOutboundMetadataDocumentResponseMessage action =
+ actionOf(MetadataDocument.parse(members()), false, true);
+ action.initialize();
+
+ ActionTestingSupport.assertProceedEvent(action.execute(requestCtx));
+
+ Assert.assertEquals(httpResponse.getHeaders("Cache-Control"), List.of("max-age=3600"));
+ Assert.assertEquals(httpResponse.getHeaders("X-Content-Type-Options"),
+ List.of(FormOutboundMetadataDocumentResponseMessage.NOSNIFF));
+ Assert.assertEquals(httpResponse.getHeaders("Content-Security-Policy"),
+ List.of(FormOutboundMetadataDocumentResponseMessage.CONTENT_SECURITY_POLICY));
+ }
+
+ @Test
+ public void testHeadersReplacePreviousValues() throws Exception {
+ httpResponse.addHeader("Cache-Control", "no-store");
+ httpResponse.addHeader("X-Content-Type-Options", "other");
+ final FormOutboundMetadataDocumentResponseMessage action =
+ actionOf(MetadataDocument.parse(members()), false, true);
+ action.setCacheControl(" max-age=60 ");
+ action.initialize();
+
+ ActionTestingSupport.assertProceedEvent(action.execute(requestCtx));
+
+ Assert.assertEquals(httpResponse.getHeaders("Cache-Control"), List.of("max-age=60"));
+ Assert.assertEquals(httpResponse.getHeaders("X-Content-Type-Options"),
+ List.of(FormOutboundMetadataDocumentResponseMessage.NOSNIFF));
+ }
+
+ @Test
+ public void testEmptyCacheControl() throws Exception {
+ final FormOutboundMetadataDocumentResponseMessage action =
+ actionOf(MetadataDocument.parse(members()), false, true);
+
+ Assert.assertThrows(ConstraintViolationException.class, () -> action.setCacheControl(" "));
+ }
+
+ @Test
+ public void testNoDocument() throws Exception {
+ final FormOutboundMetadataDocumentResponseMessage action = actionOf(null, false, true);
+ action.initialize();
+
+ ActionTestingSupport.assertEvent(action.execute(requestCtx), EventIds.IO_ERROR);
+ Assert.assertNull(profileRequestCtx.ensureOutboundMessageContext().getMessage());
+ }
+
+ @Test
+ public void testResolverFails() throws Exception {
+ final FormOutboundMetadataDocumentResponseMessage action = actionOf(null, true, true);
+ action.initialize();
+
+ ActionTestingSupport.assertEvent(action.execute(requestCtx), EventIds.IO_ERROR);
+ }
+
+ @Test
+ public void testNoHttpServletResponse() throws Exception {
+ final FormOutboundMetadataDocumentResponseMessage action =
+ actionOf(MetadataDocument.parse(members()), false, false);
+ action.initialize();
+
+ ActionTestingSupport.assertEvent(action.execute(requestCtx), IdPEventIds.INVALID_PROFILE_CONFIG);
+ Assert.assertNull(profileRequestCtx.ensureOutboundMessageContext().getMessage());
+ }
+
+ @Test(expectedExceptions = ComponentInitializationException.class)
+ public void testNoResolver() throws Exception {
+ new FormOutboundMetadataDocumentResponseMessage().initialize();
+ }
+
+ private static class StubResolver implements MetadataDocumentResolver {
+
+ private final MetadataDocument document;
+
+ private final boolean fail;
+
+ StubResolver(final MetadataDocument metadata, final boolean failing) {
+ document = metadata;
+ fail = failing;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public Iterable<MetadataDocument> resolve(final ProfileRequestContext criteria) throws ResolverException {
+ if (fail) {
+ throw new ResolverException("Testing");
+ }
+ return document == null ? List.of() : List.of(document);
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public MetadataDocument resolveSingle(final ProfileRequestContext criteria) throws ResolverException {
+ if (fail) {
+ throw new ResolverException("Testing");
+ }
+ return document;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public String getId() {
+ return "stub";
+ }
+
+ }
+
+}
--
To stop receiving notification emails like this one, please contact
the administrator of this repository.
More information about the commits
mailing list