diff --git a/components/org.wso2.carbon.identity.oauth.rar/src/main/java/org/wso2/carbon/identity/oauth/rar/core/AuthorizationDetailsSchemaValidatorImpl.java b/components/org.wso2.carbon.identity.oauth.rar/src/main/java/org/wso2/carbon/identity/oauth/rar/core/AuthorizationDetailsSchemaValidatorImpl.java index a7bcad71b15..a16532db47a 100644 --- a/components/org.wso2.carbon.identity.oauth.rar/src/main/java/org/wso2/carbon/identity/oauth/rar/core/AuthorizationDetailsSchemaValidatorImpl.java +++ b/components/org.wso2.carbon.identity.oauth.rar/src/main/java/org/wso2/carbon/identity/oauth/rar/core/AuthorizationDetailsSchemaValidatorImpl.java @@ -37,7 +37,14 @@ import org.wso2.carbon.identity.oauth.rar.exception.AuthorizationDetailsProcessingException; import org.wso2.carbon.identity.oauth.rar.model.AuthorizationDetail; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; import java.util.Map; +import java.util.Set; import static org.wso2.carbon.identity.oauth.rar.util.AuthorizationDetailsConstants.SCHEMA_VALIDATION_FAILED_ERR_MSG_FORMAT; import static org.wso2.carbon.identity.oauth.rar.util.AuthorizationDetailsConstants.TYPE_VALIDATION_FAILED_ERR_MSG_FORMAT; @@ -68,6 +75,8 @@ public class AuthorizationDetailsSchemaValidatorImpl implements AuthorizationDet private static final String ADDITIONAL_PROPERTIES = "additionalProperties"; private static final String BASE_URI = "https://wso2.com/identity-server/schemas"; + private static final Set NON_NEGATIVE_INTEGER_SCHEMA_KEYWORDS = Set.of("minItems", "maxItems", + "minLength", "maxLength", "minProperties", "maxProperties", "minContains", "maxContains"); private static volatile AuthorizationDetailsSchemaValidator instance; private final JsonSchemaOptions jsonSchemaOptions; @@ -218,9 +227,94 @@ public boolean isSchemaCompliant(final Map schema, final Authori return false; } - final JsonObject jsonSchema = new JsonObject(schema); + final JsonObject jsonSchema = new JsonObject(this.normalizeSchemaMap(schema)); jsonSchema.put(ADDITIONAL_PROPERTIES, false); // Ensure no unknown fields are allowed return this.isSchemaCompliant(jsonSchema, authorizationDetail); } + + private Map normalizeSchemaMap(final Map schema) { + + final Map normalizedSchema = new HashMap<>(); + for (Map.Entry entry : schema.entrySet()) { + normalizedSchema.put(entry.getKey(), this.normalizeSchemaValue(entry.getKey(), entry.getValue())); + } + return normalizedSchema; + } + + @SuppressWarnings("unchecked") + private Object normalizeSchemaValue(final String key, final Object value) { + + if (value instanceof Number && NON_NEGATIVE_INTEGER_SCHEMA_KEYWORDS.contains(key)) { + return this.normalizeNonNegativeIntegerSchemaKeyword(value); + } + if (value instanceof Map) { + return this.normalizeSchemaMap((Map) value); + } + if (value instanceof Collection) { + return this.normalizeSchemaCollection((Collection) value); + } + return value; + } + + @SuppressWarnings("unchecked") + private List normalizeSchemaCollection(final Collection values) { + + final List normalizedValues = new ArrayList<>(values.size()); + for (Object value : values) { + if (value instanceof Map) { + normalizedValues.add(this.normalizeSchemaMap((Map) value)); + } else if (value instanceof Collection) { + normalizedValues.add(this.normalizeSchemaCollection((Collection) value)); + } else { + normalizedValues.add(value); + } + } + return normalizedValues; + } + + private Object normalizeNonNegativeIntegerSchemaKeyword(final Object value) { + + if (!(value instanceof Number)) { + return value; + } + + final Number number = (Number) value; + if (number instanceof Integer) { + return number; + } + if (number instanceof Byte || number instanceof Short) { + final int intValue = number.intValue(); + return intValue >= 0 ? intValue : value; + } + if (number instanceof Long) { + final long longValue = number.longValue(); + return longValue >= 0 && longValue <= Integer.MAX_VALUE ? (int) longValue : value; + } + if (number instanceof BigInteger) { + return this.normalizeBigInteger((BigInteger) number, value); + } + if (number instanceof BigDecimal) { + try { + return this.normalizeBigInteger(((BigDecimal) number).toBigIntegerExact(), value); + } catch (ArithmeticException e) { + return value; + } + } + + final double doubleValue = number.doubleValue(); + if (Double.isFinite(doubleValue) && doubleValue >= 0 && doubleValue <= Integer.MAX_VALUE + && doubleValue == Math.rint(doubleValue)) { + return (int) doubleValue; + } + return value; + } + + private Object normalizeBigInteger(final BigInteger integerValue, final Object originalValue) { + + if (integerValue.signum() >= 0 && integerValue.compareTo(BigInteger.valueOf(Integer.MAX_VALUE)) <= 0) { + return integerValue.intValue(); + } + return originalValue; + } } diff --git a/components/org.wso2.carbon.identity.oauth.rar/src/test/java/org/wso2/carbon/identity/oauth/rar/AuthorizationDetailsSchemaValidatorTest.java b/components/org.wso2.carbon.identity.oauth.rar/src/test/java/org/wso2/carbon/identity/oauth/rar/AuthorizationDetailsSchemaValidatorTest.java index 35ae924def7..0e81229abca 100644 --- a/components/org.wso2.carbon.identity.oauth.rar/src/test/java/org/wso2/carbon/identity/oauth/rar/AuthorizationDetailsSchemaValidatorTest.java +++ b/components/org.wso2.carbon.identity.oauth.rar/src/test/java/org/wso2/carbon/identity/oauth/rar/AuthorizationDetailsSchemaValidatorTest.java @@ -34,6 +34,7 @@ import java.util.HashMap; import java.util.Map; +import static org.testng.Assert.assertEquals; import static org.testng.Assert.assertFalse; import static org.testng.Assert.assertTrue; import static org.wso2.carbon.identity.oauth.rar.util.TestConstants.TEST_SCHEMA; @@ -92,6 +93,162 @@ public void shouldThrowAuthorizationDetailsProcessingException_whenAuthorization assertTrue(this.uut.isSchemaCompliant(TEST_SCHEMA, testAuthorizationDetail)); } + @Test + public void shouldReturnTrue_whenMapSchemaContainsIntegralDoubleIntegerKeywords() + throws AuthorizationDetailsProcessingException { + + TestDAOUtils.TestAuthorizationDetail testAuthorizationDetail = new TestDAOUtils.TestAuthorizationDetail(); + testAuthorizationDetail.setType(TEST_TYPE); + testAuthorizationDetail.setName("test_name_v1"); + testAuthorizationDetail.setActions(Arrays.asList("initiate", "cancel")); + + Map schema = this.getTestSchemaWithDoubleIntegerKeywords(); + + assertTrue(this.uut.isSchemaCompliant(schema, testAuthorizationDetail)); + + Map actionsSchema = this.getPropertySchema(schema, "actions"); + Map nameSchema = this.getPropertySchema(schema, "name"); + assertTrue(actionsSchema.get("minItems") instanceof Double); + assertTrue(actionsSchema.get("maxItems") instanceof Double); + assertTrue(actionsSchema.get("minContains") instanceof Double); + assertTrue(actionsSchema.get("maxContains") instanceof Double); + assertTrue(nameSchema.get("minLength") instanceof Double); + assertTrue(nameSchema.get("maxLength") instanceof Double); + assertEquals(actionsSchema.get("minItems"), 1.0d); + assertEquals(actionsSchema.get("maxItems"), 3.0d); + assertEquals(actionsSchema.get("minContains"), 1.0d); + assertEquals(actionsSchema.get("maxContains"), 1.0d); + assertEquals(nameSchema.get("minLength"), 1.0d); + assertEquals(nameSchema.get("maxLength"), 20.0d); + } + + @Test(expectedExceptions = {AuthorizationDetailsProcessingException.class}) + public void shouldThrowAuthorizationDetailsProcessingException_whenMapSchemaViolatesNormalizedMaxItems() + throws AuthorizationDetailsProcessingException { + + TestDAOUtils.TestAuthorizationDetail testAuthorizationDetail = new TestDAOUtils.TestAuthorizationDetail(); + testAuthorizationDetail.setType(TEST_TYPE); + testAuthorizationDetail.setName("test_name_v1"); + testAuthorizationDetail.setActions(Arrays.asList("initiate", "cancel", "confirm", "revoke")); + + this.uut.isSchemaCompliant(this.getTestSchemaWithDoubleIntegerKeywords(), testAuthorizationDetail); + } + + @Test(expectedExceptions = {AuthorizationDetailsProcessingException.class}) + public void shouldThrowAuthorizationDetailsProcessingException_whenMapSchemaViolatesNormalizedMaxContains() + throws AuthorizationDetailsProcessingException { + + TestDAOUtils.TestAuthorizationDetail testAuthorizationDetail = new TestDAOUtils.TestAuthorizationDetail(); + testAuthorizationDetail.setType(TEST_TYPE); + testAuthorizationDetail.setName("test_name_v1"); + testAuthorizationDetail.setActions(Arrays.asList("initiate", "initiate")); + + this.uut.isSchemaCompliant(this.getTestSchemaWithDoubleIntegerKeywords(), testAuthorizationDetail); + } + + @Test + public void shouldReturnTrue_whenPropertyNameMatchesIntegerSchemaKeyword() + throws AuthorizationDetailsProcessingException { + + TestDAOUtils.TestAuthorizationDetail testAuthorizationDetail = new TestDAOUtils.TestAuthorizationDetail(); + testAuthorizationDetail.setType(TEST_TYPE); + testAuthorizationDetail.setDetail("maxLength", Collections.singletonList("initiate")); + + assertTrue(this.uut.isSchemaCompliant(this.getTestSchemaWithKeywordNamedProperty(), testAuthorizationDetail)); + } + + @Test + public void shouldReturnTrue_whenIntegerKeywordsAreAtBoundaryValues() + throws AuthorizationDetailsProcessingException { + + TestDAOUtils.TestAuthorizationDetail testAuthorizationDetail = new TestDAOUtils.TestAuthorizationDetail(); + testAuthorizationDetail.setType(TEST_TYPE); + testAuthorizationDetail.setActions(Collections.singletonList("initiate")); + + final Map items = new HashMap<>(); + items.put("type", "string"); + + final Map actions = new HashMap<>(); + actions.put("type", "array"); + actions.put("items", items); + actions.put("minItems", 0.0d); + actions.put("maxItems", (double) Integer.MAX_VALUE); + + final Map type = new HashMap<>(); + type.put("type", "string"); + type.put("enum", Collections.singletonList("test_type_v1")); + + final Map properties = new HashMap<>(); + properties.put("type", type); + properties.put("actions", actions); + + final Map schema = new HashMap<>(); + schema.put("type", "object"); + schema.put("required", Collections.singletonList("type")); + schema.put("properties", properties); + + assertTrue(this.uut.isSchemaCompliant(schema, testAuthorizationDetail)); + } + + @Test + public void shouldReturnTrue_whenIntegerKeywordsAreNestedInsideSchemaLists() + throws AuthorizationDetailsProcessingException { + + TestDAOUtils.TestAuthorizationDetail testAuthorizationDetail = new TestDAOUtils.TestAuthorizationDetail(); + testAuthorizationDetail.setType(TEST_TYPE); + testAuthorizationDetail.setName("test_name_v1"); + + final Map minLengthConstraint = new HashMap<>(); + minLengthConstraint.put("type", "string"); + minLengthConstraint.put("minLength", 2.0d); + + final Map maxLengthConstraint = new HashMap<>(); + maxLengthConstraint.put("type", "string"); + maxLengthConstraint.put("maxLength", 20.0d); + + final Map name = new HashMap<>(); + name.put("allOf", Arrays.asList(minLengthConstraint, maxLengthConstraint)); + + final Map type = new HashMap<>(); + type.put("type", "string"); + type.put("enum", Collections.singletonList("test_type_v1")); + + final Map properties = new HashMap<>(); + properties.put("type", type); + properties.put("name", name); + + final Map schema = new HashMap<>(); + schema.put("type", "object"); + schema.put("required", Collections.singletonList("type")); + schema.put("properties", properties); + + assertTrue(this.uut.isSchemaCompliant(schema, testAuthorizationDetail)); + } + + @Test + public void shouldNotMutateCallerOwnedSchema_whenValidatingMapSchema() + throws AuthorizationDetailsProcessingException { + + TestDAOUtils.TestAuthorizationDetail testAuthorizationDetail = new TestDAOUtils.TestAuthorizationDetail(); + testAuthorizationDetail.setType(TEST_TYPE); + testAuthorizationDetail.setName("test_name_v1"); + testAuthorizationDetail.setActions(Arrays.asList("initiate", "cancel")); + testAuthorizationDetail.setDetail("amount", 5); + + Map schema = this.getTestSchemaWithMixedNumericKeywords(); + + assertTrue(this.uut.isSchemaCompliant(schema, testAuthorizationDetail)); + + // The validator must not inject `additionalProperties` into the caller owned map. + assertFalse(schema.containsKey("additionalProperties")); + // Nested maps and nested lists must be left exactly as the caller provided them. + assertEquals(schema, this.getTestSchemaWithMixedNumericKeywords()); + + Map amountSchema = this.getPropertySchema(schema, "amount"); + assertTrue(amountSchema.get("minimum") instanceof Double); + assertTrue(amountSchema.get("multipleOf") instanceof Double); + } + @Test(expectedExceptions = {AuthorizationDetailsProcessingException.class}) public void shouldThrowAuthorizationDetailsProcessingException_whenSchemaIsInvalid1() throws AuthorizationDetailsProcessingException { @@ -137,4 +294,114 @@ private Map getTestSchema() { schema.put("properties", properties); return schema; } + + private Map getTestSchemaWithDoubleIntegerKeywords() { + + final Map items = new HashMap<>(); + items.put("type", "string"); + + final Map contains = new HashMap<>(); + contains.put("const", "initiate"); + + final Map actions = new HashMap<>(); + actions.put("type", "array"); + actions.put("items", items); + actions.put("minItems", 1.0d); + actions.put("maxItems", 3.0d); + actions.put("contains", contains); + actions.put("minContains", 1.0d); + actions.put("maxContains", 1.0d); + + final Map type = new HashMap<>(); + type.put("type", "string"); + type.put("enum", Collections.singletonList("test_type_v1")); + + final Map name = new HashMap<>(); + name.put("type", "string"); + name.put("minLength", 1.0d); + name.put("maxLength", 20.0d); + + final Map properties = new HashMap<>(); + properties.put("type", type); + properties.put("actions", actions); + properties.put("name", name); + + final Map schema = new HashMap<>(); + schema.put("type", "object"); + schema.put("required", Arrays.asList("type", "actions", "name")); + schema.put("properties", properties); + return schema; + } + + private Map getTestSchemaWithMixedNumericKeywords() { + + final Map items = new HashMap<>(); + items.put("type", "string"); + + final Map actions = new HashMap<>(); + actions.put("type", "array"); + actions.put("items", items); + actions.put("minItems", 1.0d); + actions.put("maxItems", 3.0d); + + final Map type = new HashMap<>(); + type.put("type", "string"); + type.put("enum", Collections.singletonList("test_type_v1")); + + final Map name = new HashMap<>(); + name.put("type", "string"); + name.put("minLength", 1.0d); + name.put("maxLength", 20.0d); + + // `minimum` and `multipleOf` accept non integral values and must never be converted. + final Map amount = new HashMap<>(); + amount.put("type", "number"); + amount.put("minimum", 1.0d); + amount.put("multipleOf", 1.0d); + + final Map properties = new HashMap<>(); + properties.put("type", type); + properties.put("actions", actions); + properties.put("name", name); + properties.put("amount", amount); + + final Map schema = new HashMap<>(); + schema.put("type", "object"); + schema.put("required", Arrays.asList("type", "actions", "name")); + schema.put("properties", properties); + return schema; + } + + private Map getTestSchemaWithKeywordNamedProperty() { + + final Map type = new HashMap<>(); + type.put("type", "string"); + type.put("enum", Collections.singletonList("test_type_v1")); + + final Map items = new HashMap<>(); + items.put("type", "string"); + + // An authorization detail property may legitimately be named after a schema keyword. + final Map keywordNamedProperty = new HashMap<>(); + keywordNamedProperty.put("type", "array"); + keywordNamedProperty.put("items", items); + keywordNamedProperty.put("minItems", 1.0d); + keywordNamedProperty.put("maxItems", 3.0d); + + final Map properties = new HashMap<>(); + properties.put("type", type); + properties.put("maxLength", keywordNamedProperty); + + final Map schema = new HashMap<>(); + schema.put("type", "object"); + schema.put("required", Collections.singletonList("type")); + schema.put("properties", properties); + return schema; + } + + @SuppressWarnings("unchecked") + private Map getPropertySchema(final Map schema, final String propertyName) { + + return (Map) ((Map) schema.get("properties")).get(propertyName); + } }