[java-oidfed-common] 01/02: Improve metadata policy deserialization especially for scopes

Codeberg noreply at shibboleth.net
Wed Sep 30 09:43:39 UTC 2026


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

codeberg pushed a commit to branch main
in repository java-oidfed-common.

View the commit online:
https://codeberg.org/Shibboleth/java-oidfed-common/commit/214a6eea64a25a7a1c1e042c96a8ce61ffb6612c

commit 214a6eea64a25a7a1c1e042c96a8ce61ffb6612c
Author: Henri Mikkonen <henri.mikkonen at iki.fi>
AuthorDate: Wed Sep 30 12:27:12 2026 +0300

    Improve metadata policy deserialization especially for scopes
    
    - Also allow the use of lists for scope: split the space-separated string into lists only for strings
    - For add, one_of, subset_of and superset_of: require lists as mandated by the spec
    - Improve testing for resolve-entity flow
---
 .../META-INF/net.shibboleth.idp/postconfig.xml     |   6 ++
 .../oidfed/flow/ResolveEntityFlowTest.java         | 115 +++++++++++++++++++++
 .../FederationMetadataPolicyDeserializer.java      |  43 +++++---
 3 files changed, 147 insertions(+), 17 deletions(-)

diff --git a/oidfed-common-conf-impl/src/main/resources/META-INF/net.shibboleth.idp/postconfig.xml b/oidfed-common-conf-impl/src/main/resources/META-INF/net.shibboleth.idp/postconfig.xml
index 4c1797c..d5c5b82 100644
--- a/oidfed-common-conf-impl/src/main/resources/META-INF/net.shibboleth.idp/postconfig.xml
+++ b/oidfed-common-conf-impl/src/main/resources/META-INF/net.shibboleth.idp/postconfig.xml
@@ -1190,6 +1190,12 @@
                     </key>
                     <bean class="com.nimbusds.oauth2.sdk.ErrorObject" c:_0="invalid_metadata" c:_1="Metadata is invalid or not found for the requested entity types" c:_2="400" />
                 </entry>
+                <entry>
+                    <key>
+                        <util:constant static-field="net.shibboleth.oidfed.profile.OidFederationEventIds.INVALID_METADATA_AGAINST_POLICY"/>
+                    </key>
+                    <bean class="com.nimbusds.oauth2.sdk.ErrorObject" c:_0="invalid_metadata" c:_1="Metadata is invalid or not found for the requested entity types against the fedeationn metadata policy" c:_2="400" />
+                </entry>
             </map>
         </property>
     </bean>
diff --git a/oidfed-common-conf-impl/src/test/java/net/shibboleth/oidfed/flow/ResolveEntityFlowTest.java b/oidfed-common-conf-impl/src/test/java/net/shibboleth/oidfed/flow/ResolveEntityFlowTest.java
index 356d39b..299e483 100644
--- a/oidfed-common-conf-impl/src/test/java/net/shibboleth/oidfed/flow/ResolveEntityFlowTest.java
+++ b/oidfed-common-conf-impl/src/test/java/net/shibboleth/oidfed/flow/ResolveEntityFlowTest.java
@@ -47,6 +47,7 @@ import com.nimbusds.jwt.JWTClaimsSet;
 import com.nimbusds.jwt.SignedJWT;
 import com.nimbusds.oauth2.sdk.ErrorResponse;
 import com.nimbusds.oauth2.sdk.Response;
+import com.nimbusds.oauth2.sdk.Scope;
 import com.nimbusds.openid.connect.sdk.rp.OIDCClientMetadata;
 
 import net.shibboleth.oidfed.messaging.impl.ResolveEntityResponse;
@@ -176,6 +177,120 @@ public class ResolveEntityFlowTest extends AbstractFederationFlowTest {
         assertErrorCode(result, "invalid_metadata");
     }
 
+    @Test
+    public void testRPWithTrustedTrustAnchorScopeFilteredAgainstPolicy() throws Exception {
+        request.setMethod("GET");
+        final String clientId = uniqueClientId();
+        final OIDCClientMetadata metadata = new OIDCClientMetadata();
+        metadata.setScope(Scope.parse("openid profile"));
+        final JWTClaimsSet claimsSet = new JWTClaimsSet.Builder().issuer(anchorId).subject(clientId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(leafKey).toJSONObject(true))
+                .claim("metadata", Map.of("openid_relying_party", metadata.toJSONObject()))
+                .claim("metadata_policy", Map.of("openid_relying_party",
+                        Map.of("scope", Map.of("subset_of", List.of("profile", "info")))))
+                .build();
+        final String subordinateStatement =
+                FederationJwtSupport.entityStatement(JWSAlgorithm.RS256, trustedAnchorKey, claimsSet).serialize();
+
+        try {
+            mapResponse(entityConfigurationUrl(clientId), mockResponse(rpEntityConfiguration(clientId, metadata)));
+            mapResponse(entityConfigurationUrl(anchorId), mockResponse(trustedAnchorConfiguration()));
+            mapResponse(subordinateStatementUrl(anchorFetchEndpoint, clientId),
+                    mockResponse(subordinateStatement));
+        } catch (UnsupportedOperationException | IOException | URISyntaxException e) {
+            Assert.fail("Could not initialize mock HTTP client", e);
+        }
+        request.setQueryString("sub=" + clientId + "&trust_anchor=" + anchorId + "&entity_type=openid_relying_party");
+        final FlowExecutionResult result = flowExecutor.launchExecution(FLOW_ID, null, externalContext);
+        final ResolveEntityResponse parsedResponse =
+                parseSuccessResponse(result, ResolveEntityResponse.class);
+        final SignedJWT response = parsedResponse.getJWT();
+        Assert.assertEquals(response.getHeader().getType(), new JOSEObjectType("resolve-response+jwt"));
+        Assert.assertEquals(response.getJWTClaimsSet().getSubject(), clientId);
+        final Map<String, Object> resultMetadata = response.getJWTClaimsSet().getJSONObjectClaim("metadata");
+        Assert.assertNotNull(resultMetadata);
+        assert resultMetadata != null;
+        @SuppressWarnings("unchecked")
+        final Map<String, Object> rpMetadata = (Map<String, Object>) resultMetadata.get("openid_relying_party");
+        final String resultScope = (String) rpMetadata.get("scope");
+        Assert.assertEquals(resultScope, "profile");
+        Assert.assertNull(response.getJWTClaimsSet().getClaim("authority_hints"));
+    }
+
+    @Test
+    public void testRPWithTrustedTrustAnchorValidScopeAgainstPolicy() throws Exception {
+        request.setMethod("GET");
+        final String clientId = uniqueClientId();
+        final OIDCClientMetadata metadata = new OIDCClientMetadata();
+        metadata.setScope(Scope.parse("openid profile"));
+        final JWTClaimsSet claimsSet = new JWTClaimsSet.Builder().issuer(anchorId).subject(clientId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(leafKey).toJSONObject(true))
+                .claim("metadata", Map.of("openid_relying_party", metadata.toJSONObject()))
+                .claim("metadata_policy", Map.of("openid_relying_party",
+                        Map.of("scope", Map.of("subset_of", List.of("profile", "info", "openid")))))
+                .build();
+        final String subordinateStatement =
+                FederationJwtSupport.entityStatement(JWSAlgorithm.RS256, trustedAnchorKey, claimsSet).serialize();
+
+        try {
+            mapResponse(entityConfigurationUrl(clientId), mockResponse(rpEntityConfiguration(clientId, metadata)));
+            mapResponse(entityConfigurationUrl(anchorId), mockResponse(trustedAnchorConfiguration()));
+            mapResponse(subordinateStatementUrl(anchorFetchEndpoint, clientId),
+                    mockResponse(subordinateStatement));
+        } catch (UnsupportedOperationException | IOException | URISyntaxException e) {
+            Assert.fail("Could not initialize mock HTTP client", e);
+        }
+        request.setQueryString("sub=" + clientId + "&trust_anchor=" + anchorId + "&entity_type=openid_relying_party");
+        final FlowExecutionResult result = flowExecutor.launchExecution(FLOW_ID, null, externalContext);
+        final ResolveEntityResponse parsedResponse =
+                parseSuccessResponse(result, ResolveEntityResponse.class);
+        final SignedJWT response = parsedResponse.getJWT();
+        Assert.assertEquals(response.getHeader().getType(), new JOSEObjectType("resolve-response+jwt"));
+        Assert.assertEquals(response.getJWTClaimsSet().getSubject(), clientId);
+        final Map<String, Object> resultMetadata = response.getJWTClaimsSet().getJSONObjectClaim("metadata");
+        Assert.assertNotNull(resultMetadata);
+        assert resultMetadata != null;
+        @SuppressWarnings("unchecked")
+        final Map<String, Object> rpMetadata = (Map<String, Object>) resultMetadata.get("openid_relying_party");
+        final String resultScope = (String) rpMetadata.get("scope");
+        Assert.assertEquals(resultScope, "openid profile");
+        Assert.assertNull(response.getJWTClaimsSet().getClaim("authority_hints"));
+    }
+
+    @Test
+    public void testRPWithTrustedTrustAnchorInvalidScopeAgainstPolicy() throws Exception {
+        request.setMethod("GET");
+        final String clientId = uniqueClientId();
+        final OIDCClientMetadata metadata = new OIDCClientMetadata();
+        metadata.setScope(Scope.parse("openid profile"));
+        final JWTClaimsSet claimsSet = new JWTClaimsSet.Builder().issuer(anchorId).subject(clientId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(leafKey).toJSONObject(true))
+                .claim("metadata", Map.of("openid_relying_party", metadata.toJSONObject()))
+                .claim("metadata_policy", Map.of("openid_relying_party",
+                        Map.of("scope", Map.of("superset_of", List.of("info")))))
+                .build();
+        final String subordinateStatement =
+                FederationJwtSupport.entityStatement(JWSAlgorithm.RS256, trustedAnchorKey, claimsSet).serialize();
+
+        try {
+            mapResponse(entityConfigurationUrl(clientId), mockResponse(rpEntityConfiguration(clientId, metadata)));
+            mapResponse(entityConfigurationUrl(anchorId), mockResponse(trustedAnchorConfiguration()));
+            mapResponse(subordinateStatementUrl(anchorFetchEndpoint, clientId),
+                    mockResponse(subordinateStatement));
+        } catch (UnsupportedOperationException | IOException | URISyntaxException e) {
+            Assert.fail("Could not initialize mock HTTP client", e);
+        }
+        request.setQueryString("sub=" + clientId + "&trust_anchor=" + anchorId + "&entity_type=openid_relying_party");
+        final FlowExecutionResult result = flowExecutor.launchExecution(FLOW_ID, null, externalContext);
+        assertErrorCode(result, "invalid_metadata");
+    }
+
     @Test
     public void testOPWithTrustedTrustAnchor() throws Exception {
         request.setMethod("GET");
diff --git a/oidfed-common-impl/src/main/java/net/shibboleth/oidfed/metadata/policy/FederationMetadataPolicyDeserializer.java b/oidfed-common-impl/src/main/java/net/shibboleth/oidfed/metadata/policy/FederationMetadataPolicyDeserializer.java
index eb7d171..e03af5e 100644
--- a/oidfed-common-impl/src/main/java/net/shibboleth/oidfed/metadata/policy/FederationMetadataPolicyDeserializer.java
+++ b/oidfed-common-impl/src/main/java/net/shibboleth/oidfed/metadata/policy/FederationMetadataPolicyDeserializer.java
@@ -94,36 +94,40 @@ public class FederationMetadataPolicyDeserializer extends JsonDeserializer<Metad
             for (final String key : map.keySet().stream().filter(Objects::nonNull).toList()) {
                 switch (key) {
                     case "value":
-                        final Object value = FederationMetadataPolicyHelper.transformSpaceSeparatedStringIntoList(
-                                arraysAsSpaceSeparatedList, claim, map.get("value"));
+                        final Object value;
+                        if (map.get("value") instanceof String) {
+                            value = FederationMetadataPolicyHelper.transformSpaceSeparatedStringIntoList(
+                                    arraysAsSpaceSeparatedList, claim, map.get("value"));
+                        } else {
+                            value = map.get("value");
+                        }
                         policy.setValue(value != null ? value : Optional.empty());
                         break;
                     case "add":
-                        policy.setAdd(FederationMetadataPolicyHelper.transformSpaceSeparatedStringIntoList(
-                                arraysAsSpaceSeparatedList, claim, map.get("add")));
+                        policy.setAdd(transformObjectIntoList("add", map.get("add")));
                         break;
                     case "default":
-                        policy.setDefaultValue(FederationMetadataPolicyHelper.transformSpaceSeparatedStringIntoList(
-                                arraysAsSpaceSeparatedList, claim, map.get("default")));
+                        final Object defaultValue = map.get("default");
+                        if (defaultValue instanceof String) {
+                            policy.setDefaultValue(
+                                    FederationMetadataPolicyHelper.transformSpaceSeparatedStringIntoList(
+                                            arraysAsSpaceSeparatedList, claim, defaultValue));
+                        } else {
+                            policy.setDefaultValue(defaultValue);
+                        }
                         break;
                     case "essential":
                         policy.setEssential(map.get("essential") != null ?
                                 Boolean.valueOf(String.valueOf(map.get("essential"))).booleanValue() : false);
                         break;
                     case "one_of":
-                        policy.setOneOfValues(transformObjectIntoList("one_of",
-                                FederationMetadataPolicyHelper.transformSpaceSeparatedStringIntoList(
-                                        arraysAsSpaceSeparatedList, claim, map.get("one_of"))));
+                        policy.setOneOfValues(transformObjectIntoList("one_of", map.get("one_of")));
                         break;
                     case "subset_of":
-                        policy.setSubsetOfValues(transformObjectIntoList("subset_of",
-                                FederationMetadataPolicyHelper.transformSpaceSeparatedStringIntoList(
-                                        arraysAsSpaceSeparatedList, claim, map.get("subset_of"))));
+                        policy.setSubsetOfValues(transformObjectIntoList("subset_of", map.get("subset_of")));
                         break;
                     case "superset_of":
-                        policy.setSupersetOfValues(transformObjectIntoList("superset_of",
-                                FederationMetadataPolicyHelper.transformSpaceSeparatedStringIntoList(
-                                        arraysAsSpaceSeparatedList, claim, map.get("superset_of"))));
+                        policy.setSupersetOfValues(transformObjectIntoList("superset_of", map.get("superset_of")));
                         break;
                     case "regexp":
                         policy.setRegexp(map.get("regexp") == null ? null : "" + map.get("regexp"));
@@ -145,15 +149,20 @@ public class FederationMetadataPolicyDeserializer extends JsonDeserializer<Metad
      * @param id the key for the map of objects
      * @param object the object value
      * @return the value for the key as list or null
-     * @throws IOException if a non-null value could not be transformed into a list
+     * @throws IOException if a non-null value could not be transformed into a list or the list contained null elements
      */
     @Nullable private List<Object> transformObjectIntoList(@Nonnull final String id,
             @Nullable final Object object) throws IOException {
         if (object instanceof List<?> list) {
-            return list.stream().filter(Object.class::isInstance).map(Object.class::cast).toList();
+            final List<Object> result = list.stream().map(Object.class::cast).toList();
+            if (result.contains(null)) {
+                throw new IOException("The list value '" + object + "' for '" + id + "' contains null elements");
+            }
+            return result;
         } else if (object != null) {
             throw new IOException("The value '" + object + "' for '" + id + "' is not a list");
         }
         return null;
     }
+
 }
\ No newline at end of file

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


More information about the commits mailing list