diff --git a/openig-core/src/main/java/org/forgerock/openig/alias/CoreClassAliasResolver.java b/openig-core/src/main/java/org/forgerock/openig/alias/CoreClassAliasResolver.java index 6c72f135b..4d6a6e75c 100644 --- a/openig-core/src/main/java/org/forgerock/openig/alias/CoreClassAliasResolver.java +++ b/openig-core/src/main/java/org/forgerock/openig/alias/CoreClassAliasResolver.java @@ -62,10 +62,12 @@ import org.forgerock.openig.security.TrustManagerHeaplet; import org.forgerock.openig.thread.ScheduledExecutorServiceHeaplet; import org.openidentityplatform.openig.filter.ICAPFilter; +import org.openidentityplatform.openig.filter.JwtBuilderFilter; import org.openidentityplatform.openig.filter.MCPServerFeaturesFilter; import org.openidentityplatform.openig.mq.EmbeddedKafka; import org.openidentityplatform.openig.mq.MQ_IBM; import org.openidentityplatform.openig.mq.MQ_Kafka; +import org.openidentityplatform.openig.secrets.SystemAndEnvSecretStore; /** * Register all the aliases supported by the {@literal openig-core} module. @@ -93,6 +95,7 @@ public class CoreClassAliasResolver implements ClassAliasResolver { ALIASES.put("FileAttributesFilter", FileAttributesFilter.class); ALIASES.put("HeaderFilter", HeaderFilter.class); ALIASES.put("HttpBasicAuthFilter", HttpBasicAuthFilter.class); + ALIASES.put("JwtBuilderFilter", JwtBuilderFilter.class); ALIASES.put("JwtSessionFactory", JwtSessionManager.class); ALIASES.put("JwtSession", JwtSessionManager.class); ALIASES.put("KeyManager", KeyManagerHeaplet.class); @@ -121,7 +124,12 @@ public class CoreClassAliasResolver implements ClassAliasResolver { ALIASES.put("MQ_Kafka", MQ_Kafka.class); ALIASES.put("MQ_IBM", MQ_IBM.class); ALIASES.put("ICAP", ICAPFilter.class); + + //AI features ALIASES.put("MCPServerFeaturesFilter", MCPServerFeaturesFilter.class); + + //Secrets + ALIASES.put("SystemAndEnvSecretStore", SystemAndEnvSecretStore.class); } @Override diff --git a/openig-core/src/main/java/org/forgerock/openig/el/ExpressionInstant.java b/openig-core/src/main/java/org/forgerock/openig/el/ExpressionInstant.java new file mode 100644 index 000000000..a59fc366b --- /dev/null +++ b/openig-core/src/main/java/org/forgerock/openig/el/ExpressionInstant.java @@ -0,0 +1,84 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Copyright 2026 3A Systems LLC. + */ + +package org.forgerock.openig.el; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; + +/** + * {@link java.time.Instant} wrapper to use in OpenIG expression language + * + * @see org.forgerock.openig.el.plugins.ExpressionInstantPlugin + * + */ + +public class ExpressionInstant { + + Instant instant; + + public ExpressionInstant(Instant instant) { + this.instant = instant; + } + public long getEpochMillis() { + return this.instant.toEpochMilli(); + } + + public long getEpochSeconds() { + return this.instant.getEpochSecond(); + } + + public ExpressionInstant minusDays(long daysToSubtract) { + return new ExpressionInstant(this.instant.minus(daysToSubtract, ChronoUnit.DAYS)); + } + + + public ExpressionInstant minusHours(long hoursToSubtract) { + return new ExpressionInstant(this.instant.minus(hoursToSubtract, ChronoUnit.HOURS)); + } + + public ExpressionInstant minusMillis(long millisecondsToSubtract) { + return new ExpressionInstant(this.instant.minusMillis(millisecondsToSubtract)); + } + + public ExpressionInstant minusMinutes(long minutesToSubtract) { + return new ExpressionInstant(this.instant.minus(minutesToSubtract, ChronoUnit.MINUTES)); + } + + public ExpressionInstant minusSeconds(long secondsToSubtract) { + return new ExpressionInstant(this.instant.minus(secondsToSubtract, ChronoUnit.SECONDS)); + } + + public ExpressionInstant plusDays(long daysToAdd) { + return new ExpressionInstant(this.instant.plus(daysToAdd, ChronoUnit.DAYS)); + } + + public ExpressionInstant plusHours(long hoursToAdd) { + return new ExpressionInstant(this.instant.plus(hoursToAdd, ChronoUnit.DAYS)); + } + + public ExpressionInstant plusMillis(long millisecondsToAdd) { + return new ExpressionInstant(this.instant.plus(millisecondsToAdd, ChronoUnit.DAYS)); + } + + public ExpressionInstant plusMinutes(long minutesToAdd) { + return new ExpressionInstant(this.instant.plus(minutesToAdd, ChronoUnit.DAYS)); + } + + public ExpressionInstant plusSeconds(long secondsToAdd) { + return new ExpressionInstant(this.instant.plus(secondsToAdd, ChronoUnit.DAYS)); + } +} diff --git a/openig-core/src/main/java/org/forgerock/openig/el/plugins/ExpressionInstantPlugin.java b/openig-core/src/main/java/org/forgerock/openig/el/plugins/ExpressionInstantPlugin.java new file mode 100644 index 000000000..bcb61e597 --- /dev/null +++ b/openig-core/src/main/java/org/forgerock/openig/el/plugins/ExpressionInstantPlugin.java @@ -0,0 +1,38 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Copyright 2026 3A Systems LLC. + */ + +package org.forgerock.openig.el.plugins; + +import org.forgerock.openig.el.ExpressionInstant; +import org.forgerock.openig.el.ExpressionPlugin; + +import java.time.Instant; + + +/** + * An ELContext node plugin that provides access to {@link ExpressionInstant} instance. + */ +public class ExpressionInstantPlugin implements ExpressionPlugin { + @Override + public Object getObject() { + return new ExpressionInstant(Instant.now()); + } + + @Override + public String getKey() { + return "now"; + } +} diff --git a/openig-core/src/main/java/org/forgerock/openig/util/JsonValues.java b/openig-core/src/main/java/org/forgerock/openig/util/JsonValues.java index 54a82b17e..20cbcd96f 100644 --- a/openig-core/src/main/java/org/forgerock/openig/util/JsonValues.java +++ b/openig-core/src/main/java/org/forgerock/openig/util/JsonValues.java @@ -12,6 +12,7 @@ * information: "Portions copyright [year] [name of copyright owner]". * * Copyright 2015-2016 ForgeRock AS. + * Portions copyright 2026 3A Systems LLC. */ package org.forgerock.openig.util; @@ -19,13 +20,16 @@ import static java.util.Collections.unmodifiableList; import static org.forgerock.http.util.Json.readJsonLenient; import static org.forgerock.http.util.Loader.loadList; +import static org.forgerock.json.JsonValue.object; import static org.forgerock.openig.util.StringUtil.trailingSlash; import java.io.IOException; import java.io.InputStream; import java.net.URL; import java.util.List; +import java.util.Map; +import org.forgerock.json.JsonException; import org.forgerock.json.JsonValue; import org.forgerock.json.JsonValueException; import org.forgerock.openig.alias.ClassAliasResolver; @@ -449,4 +453,41 @@ public static JsonValue readJson(URL resource) throws IOException { return new JsonValue(readJsonLenient(in)); } } + + public static Function, ExpressionException> + asFunction(final JsonValue node, final Class expectedType, final Bindings initialBindings) { + if (node.isNull()) { + return null; + } else if (node.isString()) { + return bindings -> node.as(JsonValues.expression(Map.class, initialBindings)).eval(bindings); + } else if (node.isMap()) { + return new Function<>() { + // Avoid 'environment' entry's value to be null (cause an error in AM) + Function filterNullMapValues = + value -> { + if (!value.isMap()) { + return value; + } + + Map object = object(); + for (String key : value.keys()) { + JsonValue entry = value.get(key); + if (entry.isNotNull()) { + object.put(key, entry.getObject()); + } + } + return new JsonValue(object, value.getPointer()); + }; + + @Override + public Map apply(Bindings bindings) { + return node.as(evaluated(bindings)) + .as(filterNullMapValues) // see OPENIG-1402 and AME-12483 + .asMap(expectedType); + } + }; + } else { + throw new JsonValueException(node, "Expecting a String or a Map"); + } + } } diff --git a/openig-core/src/main/java/org/openidentityplatform/openig/filter/JwtBuilderContext.java b/openig-core/src/main/java/org/openidentityplatform/openig/filter/JwtBuilderContext.java new file mode 100644 index 000000000..6e97a1dc4 --- /dev/null +++ b/openig-core/src/main/java/org/openidentityplatform/openig/filter/JwtBuilderContext.java @@ -0,0 +1,53 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Copyright 2026 3A Systems LLC. + */ + +package org.openidentityplatform.openig.filter; + +import org.forgerock.json.JsonValue; +import org.forgerock.services.context.AbstractContext; +import org.forgerock.services.context.Context; + +import java.util.Map; + +import static org.forgerock.json.JsonValue.json; + +public class JwtBuilderContext extends AbstractContext { + + private final String value; + + private final Map claims; + + private final JsonValue claimsAsJsonValue; + + JwtBuilderContext(Context parent, String value, Map claims) { + super(parent, "jwtBuilder"); + this.value = value; + this.claims = claims; + this.claimsAsJsonValue = json(claims); + } + + public String getValue() { + return value; + } + + public Map getClaims() { + return claims; + } + + public JsonValue getClaimsAsJsonValue() { + return claimsAsJsonValue; + } +} diff --git a/openig-core/src/main/java/org/openidentityplatform/openig/filter/JwtBuilderFilter.java b/openig-core/src/main/java/org/openidentityplatform/openig/filter/JwtBuilderFilter.java new file mode 100644 index 000000000..6eb5c7ad5 --- /dev/null +++ b/openig-core/src/main/java/org/openidentityplatform/openig/filter/JwtBuilderFilter.java @@ -0,0 +1,257 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Copyright 2026 3A Systems LLC. + */ + +package org.openidentityplatform.openig.filter; + +import org.forgerock.http.Filter; +import org.forgerock.http.Handler; +import org.forgerock.http.protocol.Request; +import org.forgerock.http.protocol.Response; +import org.forgerock.http.protocol.Status; +import org.forgerock.json.JsonValue; +import org.forgerock.json.jose.builders.JwtBuilderFactory; +import org.forgerock.json.jose.builders.JwtClaimsSetBuilder; +import org.forgerock.json.jose.builders.SignedJwtBuilderImpl; +import org.forgerock.json.jose.builders.SignedThenEncryptedJwtBuilder; +import org.forgerock.json.jose.jwe.EncryptionMethod; +import org.forgerock.json.jose.jwe.JweAlgorithm; +import org.forgerock.json.jose.jws.JwsAlgorithm; +import org.forgerock.json.jose.jws.SigningManager; +import org.forgerock.json.jose.jws.handlers.SigningHandler; +import org.forgerock.json.jose.jwt.Jwt; +import org.forgerock.json.jose.jwt.JwtClaimsSet; +import org.forgerock.openig.el.Bindings; +import org.forgerock.openig.el.ExpressionException; +import org.forgerock.openig.heap.GenericHeaplet; +import org.forgerock.openig.heap.Heap; +import org.forgerock.openig.heap.HeapException; +import org.forgerock.openig.util.JsonValues; +import org.forgerock.services.context.Context; +import org.forgerock.util.Function; +import org.forgerock.util.promise.NeverThrowsException; +import org.forgerock.util.promise.Promise; +import org.openidentityplatform.openig.secrets.SystemAndEnvSecretStore; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.crypto.spec.SecretKeySpec; +import java.security.Key; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.interfaces.ECPrivateKey; +import java.security.interfaces.RSAPrivateKey; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.X509EncodedKeySpec; +import java.util.Map; + +import static org.forgerock.http.protocol.Response.newResponsePromise; +import static org.forgerock.json.JsonValueFunctions.enumConstant; +import static org.forgerock.openig.el.Bindings.bindings; + + +/** + * Filter that builds JWTs from configurable claims templates. + *

+ * Supports signing with HMAC, RSA, and ECDSA algorithms, and optional + * encryption with RSA or AES key wrapping. + * + *

Configuration example: + *

{@code
+ * {
+ *   "type": "JwtBuilderFilter",
+ *   "config": {
+ *      "template": {
+ *          "sub": "${request.headers['X-User-ID'][0]}",
+ *          "iss": "my-issuer"
+ *      },
+ *      "signature": {
+ *          "algorithm": "RS256",
+ *          "secretId": "jwt.signing.key"
+ *      },
+ *     "encryption": {
+ *          "algorithm": "RSA-OAEP",
+ *           "method": "A128GCM",
+ *          "secretId": "jwt.encryption.key"
+ *      }
+ *   }
+ * }
+ * }
+ * + * @see JwtBuilderContext + */ +public class JwtBuilderFilter implements Filter { + + private static final Logger logger = LoggerFactory.getLogger(JwtBuilderFilter.class); + + private Function, ExpressionException> template; + + private Signature signature; + + private Encryption encryption; + + @Override + public Promise filter(Context context, Request request, Handler next) { + final Bindings bindings = bindings(context, request); + Map claims; + try { + claims = template.apply(bindings); + } catch (ExpressionException e) { + logger.error("Failed to evaluate claims template", e); + return newResponsePromise(new Response(Status.INTERNAL_SERVER_ERROR)); + } + + JwtClaimsSetBuilder jwtClaimsSetBuilder = new JwtClaimsSetBuilder(); + JwtClaimsSet jwtClaims = jwtClaimsSetBuilder.claims(claims).build(); + + JwtBuilderFactory jwtBuilderFactory = new JwtBuilderFactory(); + + SignedJwtBuilderImpl jwtBuilder; + if(this.signature != null) { + jwtBuilder = jwtBuilderFactory.jws(this.signature.signingHandler).headers().alg(this.signature.algorithm).done(); + } else { + jwtBuilder = jwtBuilderFactory.jwt().headers().done(); + } + + jwtBuilder = jwtBuilder.claims(jwtClaims); + + Jwt jwt; + if (this.encryption != null) { + SignedThenEncryptedJwtBuilder signedThenEncryptedJwtBuilder + = jwtBuilder.encrypt(this.encryption.key).headers() + .enc(this.encryption.method).alg(this.encryption.algorithm).done(); + jwt = signedThenEncryptedJwtBuilder.asJwt(); + + } else { + jwt = jwtBuilder.asJwt(); + } + return next.handle(new JwtBuilderContext(context, jwt.build(), claims), request); + } + + static class Signature { + + private final JwsAlgorithm algorithm; + private final byte[] secret; + + private final SigningHandler signingHandler; + + + Signature(JsonValue config, Heap heap) throws HeapException { + this.algorithm = config.get("algorithm").defaultTo("RS256").as(enumConstant(JwsAlgorithm.class)); + String secretId = config.get("secretId").required().asString(); + this.secret = SystemAndEnvSecretStore.getSecretFromHeap(heap, secretId); + if (this.secret == null || this.secret.length == 0) { + throw new HeapException("Secret cannot be null or empty"); + } + + try { + this.signingHandler = initSigningHandler(); + } catch (Exception e) { + throw new HeapException("Error initializing signing handler", e); + } + } + + private SigningHandler initSigningHandler() throws NoSuchAlgorithmException, InvalidKeySpecException { + SigningHandler signingHandler; + switch (this.algorithm.getAlgorithmType()) { + case HMAC: { + signingHandler = new SigningManager().newHmacSigningHandler(this.secret); + break; + } + case RSA: { + KeyFactory rsaFact = KeyFactory.getInstance("RSA"); + PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(this.secret); + RSAPrivateKey key = (RSAPrivateKey) rsaFact.generatePrivate(spec); + signingHandler = new SigningManager().newRsaSigningHandler(key); + break; + } + case ECDSA: { + KeyFactory ecFact = KeyFactory.getInstance("EC"); + PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(this.secret); + ECPrivateKey key = (ECPrivateKey) ecFact.generatePrivate(spec); + signingHandler = new SigningManager().newEcdsaSigningHandler(key); + break; + } + default: + signingHandler = null; + break; + } + return signingHandler; + } + } + + static class Encryption { + + private final byte[] secret; + private final JweAlgorithm algorithm; + private final EncryptionMethod method; + + private final Key key; + + Encryption(JsonValue config, Heap heap) throws HeapException { + String secretId = config.get("secretId").required().asString(); + this.secret = SystemAndEnvSecretStore.getSecretFromHeap(heap, secretId); + + if (this.secret == null || this.secret.length == 0) { + throw new HeapException("Secret cannot be null or empty"); + } + + this.algorithm = config.get("algorithm").required().as(a -> JweAlgorithm.parseAlgorithm(a.asString())); + this.method = config.get("method").required().as(a -> EncryptionMethod.parseMethod(a.asString())); + try { + this.key = initEncryptionKey(); + }catch (Exception e) { + throw new HeapException("Error initializing encryption key", e); + } + } + + private Key initEncryptionKey() throws NoSuchAlgorithmException, InvalidKeySpecException { + + switch (algorithm) { + case RSA_OAEP: + case RSA_OAEP_256: + case RSAES_PKCS1_V1_5: + KeyFactory rsaFact = KeyFactory.getInstance("RSA"); + X509EncodedKeySpec spec = new X509EncodedKeySpec(secret); + return rsaFact.generatePublic(spec); + case DIRECT: + case A128KW: + case A192KW: + case A256KW: + return new SecretKeySpec(secret, "AES"); + default: + throw new IllegalArgumentException("Unsupported algorithm: " + algorithm); + } + } + } + + public static class Heaplet extends GenericHeaplet { + @Override + public Object create() throws HeapException { + JwtBuilderFilter filter = new JwtBuilderFilter(); + + filter.template = JsonValues.asFunction(config.get("template").required(), Object.class, heap.getProperties()); + + if(config.isDefined("signature")) { + filter.signature = new Signature(config.get("signature"), heap); + } + if(config.isDefined("encryption")) { + filter.encryption = new Encryption(config.get("encryption"), heap); + } + return filter; + } + } +} diff --git a/openig-core/src/main/java/org/openidentityplatform/openig/filter/MCPServerFeaturesFilter.java b/openig-core/src/main/java/org/openidentityplatform/openig/filter/MCPServerFeaturesFilter.java index 3e2be5c24..419182b92 100644 --- a/openig-core/src/main/java/org/openidentityplatform/openig/filter/MCPServerFeaturesFilter.java +++ b/openig-core/src/main/java/org/openidentityplatform/openig/filter/MCPServerFeaturesFilter.java @@ -61,7 +61,7 @@ *
  • Denied features are always blocked, regardless of allow list
  • * * - *
    + * 
    {@code
      * {
      *     "type": "MCPFeaturesFilter",
      *     "config": {
    @@ -75,6 +75,7 @@
      *         }
      *     }
      * }
    + * }
      * 
    */ public class MCPServerFeaturesFilter implements Filter { diff --git a/openig-core/src/main/java/org/openidentityplatform/openig/secrets/SystemAndEnvSecretStore.java b/openig-core/src/main/java/org/openidentityplatform/openig/secrets/SystemAndEnvSecretStore.java new file mode 100644 index 000000000..4314de2b2 --- /dev/null +++ b/openig-core/src/main/java/org/openidentityplatform/openig/secrets/SystemAndEnvSecretStore.java @@ -0,0 +1,101 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Copyright 2026 3A Systems LLC. + */ + +package org.openidentityplatform.openig.secrets; + +import org.forgerock.json.JsonValue; +import org.forgerock.openig.heap.GenericHeaplet; +import org.forgerock.openig.heap.Heap; +import org.forgerock.openig.heap.HeapException; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.List; +import java.util.Objects; + +import static org.forgerock.json.JsonValueFunctions.enumConstant; + +/** + * Secret store that retrieves secrets from environment variables and system properties. + * Environment variables are checked first, with system properties as fallback. + *
    + * {
    + *     "type": "SystemAndEnvSecretStore",
    + *     "config": {
    + *         "format": "PLAIN"
    + *     }
    + * }
    + * 
    + */ +public class SystemAndEnvSecretStore { + + private final Format format; + + public SystemAndEnvSecretStore(Format format) { + this.format = format; + } + + public byte[] getSecret(String id) { + final String key = id.toUpperCase().replaceAll("\\.", "_"); + String value = System.getenv(key); + + if (value == null) { + value = System.getProperty(key); + } + if (value == null) { + return null; + } + + return decodeValue(value); + } + private byte[] decodeValue(String value) { + if (Format.BASE64.equals(format)) { + try { + return Base64.getDecoder().decode(value); + } catch (IllegalArgumentException e) { + throw new IllegalStateException( + "Invalid Base64 encoding for secret value", e); + } + } + return value.getBytes(StandardCharsets.UTF_8); + } + + public static byte[] getSecretFromHeap(Heap heap, String id) throws HeapException { + List secretStores = heap.getAll(SystemAndEnvSecretStore.class); + + return secretStores.stream() + .map(secretStore -> secretStore.getSecret(id)) + .filter(Objects::nonNull) + .findFirst() + .orElse(null); + } + + public enum Format { + PLAIN, + BASE64 + } + + public static class Heaplet extends GenericHeaplet { + @Override + public Object create() throws HeapException { + JsonValue evaluatedConfig = config.as(evaluatedWithHeapProperties()); + Format format = evaluatedConfig.get("format") + .defaultTo("BASE64") + .as(enumConstant(Format.class)); + return new SystemAndEnvSecretStore(format); + } + } +} diff --git a/openig-core/src/main/resources/META-INF/services/org.forgerock.openig.el.ExpressionPlugin b/openig-core/src/main/resources/META-INF/services/org.forgerock.openig.el.ExpressionPlugin index 7a5f691b6..03ede89b8 100644 --- a/openig-core/src/main/resources/META-INF/services/org.forgerock.openig.el.ExpressionPlugin +++ b/openig-core/src/main/resources/META-INF/services/org.forgerock.openig.el.ExpressionPlugin @@ -12,7 +12,9 @@ # information: "Portions copyright [year] [name of copyright owner]". # # Copyright 2014-2015 ForgeRock AS. +# Portions copyright 2026 3A Systems LLC. # org.forgerock.openig.el.plugins.EnvironmentVariablesPlugin org.forgerock.openig.el.plugins.SystemPropertiesPlugin +org.forgerock.openig.el.plugins.ExpressionInstantPlugin diff --git a/openig-core/src/test/java/org/forgerock/openig/el/ExpressionInstantTest.java b/openig-core/src/test/java/org/forgerock/openig/el/ExpressionInstantTest.java new file mode 100644 index 000000000..372ad2e61 --- /dev/null +++ b/openig-core/src/test/java/org/forgerock/openig/el/ExpressionInstantTest.java @@ -0,0 +1,30 @@ +package org.forgerock.openig.el; + +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.forgerock.openig.el.Bindings.bindings; + +public class ExpressionInstantTest { + private Bindings bindings; + + @BeforeMethod + public void beforeMethod() { + bindings = bindings(); + } + @Test + public void testExpressionInstant() throws ExpressionException { + ExpressionInstant instant = Expression.valueOf("${now}", + ExpressionInstant.class).eval(bindings); + assertThat(instant).isNotNull(); + assertThat(instant.getEpochMillis()).isGreaterThan(0); + } + + @Test + public void testExpressionInstantAddDays() throws ExpressionException { + Long seconds = Expression.valueOf("${now.plusMinutes(30).epochSeconds}", + Long.class).eval(bindings); + assertThat(seconds).isGreaterThan(0); + } +} \ No newline at end of file diff --git a/openig-core/src/test/java/org/forgerock/openig/util/JsonValuesTest.java b/openig-core/src/test/java/org/forgerock/openig/util/JsonValuesTest.java index dce6985e4..d5793f0f9 100644 --- a/openig-core/src/test/java/org/forgerock/openig/util/JsonValuesTest.java +++ b/openig-core/src/test/java/org/forgerock/openig/util/JsonValuesTest.java @@ -12,6 +12,7 @@ * information: "Portions copyright [year] [name of copyright owner]". * * Copyright 2015-2016 ForgeRock AS. + * Portions Copyright 2026 3A Systems LLC. */ package org.forgerock.openig.util; @@ -22,6 +23,8 @@ import static org.forgerock.json.JsonValue.json; import static org.forgerock.json.JsonValue.object; import static org.forgerock.json.JsonValueFunctions.listOf; +import static org.forgerock.openig.el.Bindings.bindings; +import static org.forgerock.openig.util.JsonValues.asFunction; import static org.forgerock.openig.util.JsonValues.evaluated; import static org.forgerock.openig.util.JsonValues.firstOf; import static org.forgerock.openig.util.JsonValues.getWithDeprecation; @@ -39,10 +42,9 @@ import org.forgerock.json.JsonPointer; import org.forgerock.json.JsonValue; import org.forgerock.openig.el.Bindings; +import org.forgerock.openig.el.ExpressionException; import org.forgerock.openig.heap.Heap; -import org.hamcrest.BaseMatcher; -import org.hamcrest.Description; -import org.hamcrest.Matcher; +import org.forgerock.util.Function; import org.mockito.ArgumentCaptor; import org.mockito.ArgumentMatcher; import org.mockito.Captor; @@ -53,6 +55,9 @@ import org.testng.annotations.DataProvider; import org.testng.annotations.Test; +import java.util.List; +import java.util.Map; + @SuppressWarnings("javadoc") public class JsonValuesTest { @@ -275,4 +280,44 @@ public boolean matches(final JsonValue json) { } }; } + + @SuppressWarnings("rawtypes") + @Test(dataProvider = "asFunctionProvider") + public void shouldEvaluateToMap(JsonValue input, Map expectedOutput) throws Exception { + Bindings bindings = bindings().bind("foo", object(field("bar", array(1, 2, 3)))); + + Function, ExpressionException> function = + asFunction(input, List.class, bindings()); + + assertThat(function.apply(bindings)).isEqualTo(expectedOutput); + } + + @SuppressWarnings("rawtypes") + @Test + public void shouldReturnNullWhenJsonValueInputIsNull() throws Exception { + Function, ExpressionException> function = + asFunction(json(null), List.class, bindings()); + assertThat(function).isNull(); + } + + @DataProvider + public static Object[][] asFunctionProvider() { + //@Checkstyle:off + return new Object[][]{ + { + json("${foo}"), + object(field("bar", array(1, 2, 3))) + }, + { + json(object(field("quix", "${foo['bar']}"))), + object(field("quix", array(1, 2, 3))) + }, + { + // keys with null values will be dropped + json(object(field("foo", null), field("bar", array("quix")))), + object(field("bar", array("quix"))) + } + }; + //@Checkstyle:on + } } diff --git a/openig-core/src/test/java/org/openidentityplatform/openig/filter/JwtBuilderFilterTest.java b/openig-core/src/test/java/org/openidentityplatform/openig/filter/JwtBuilderFilterTest.java new file mode 100644 index 000000000..90f5ebfff --- /dev/null +++ b/openig-core/src/test/java/org/openidentityplatform/openig/filter/JwtBuilderFilterTest.java @@ -0,0 +1,276 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Copyright 2026 3A Systems LLC. + */ + +package org.openidentityplatform.openig.filter; + +import org.forgerock.http.Handler; +import org.forgerock.http.protocol.Request; +import org.forgerock.http.protocol.Response; +import org.forgerock.http.protocol.Status; +import org.forgerock.json.JsonValue; +import org.forgerock.openig.heap.HeapException; +import org.forgerock.openig.heap.HeapImpl; +import org.forgerock.openig.heap.HeapUtilsTest; +import org.forgerock.openig.heap.Name; +import org.forgerock.services.context.AttributesContext; +import org.forgerock.services.context.Context; +import org.forgerock.services.context.RootContext; +import org.mockito.ArgumentCaptor; +import org.openidentityplatform.openig.secrets.SystemAndEnvSecretStore; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; + +import javax.crypto.KeyGenerator; +import java.nio.charset.StandardCharsets; +import java.security.InvalidAlgorithmParameterException; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.security.spec.ECGenParameterSpec; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.forgerock.http.protocol.Response.newResponsePromise; +import static org.forgerock.json.JsonValue.field; +import static org.forgerock.json.JsonValue.json; +import static org.forgerock.json.JsonValue.object; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class JwtBuilderFilterTest { + final static String SECRET_VALUE = "s3cr3t"; + + Handler mockHandler; + SystemAndEnvSecretStore mockSecretStore; + + HeapImpl heap; + + Request request; + @BeforeMethod + public void setup() { + mockHandler = mock(Handler.class); + mockSecretStore = mock(SystemAndEnvSecretStore.class); + + when(mockHandler.handle(any(Context.class), any(Request.class))) + .thenReturn(newResponsePromise(new Response(Status.OK))); + + heap = HeapUtilsTest.buildDefaultHeap(); + heap.put("secretStore", mockSecretStore); + + request = new Request(); + } + + + + @Test + public void testFilterInitialization() throws HeapException { + + JsonValue config = json(object( + field("template", + object(field("sub", "${attributes.subject}"))), + field("signature", + object( + field("secretId", "jwt.signing.key"), + field("algorithm", "HS256") + )), + field("encryption", + object( + field("secretId", "jwt.encryption.key"), + field("method", "A256GCM"), + field("algorithm", "A256KW") + )) + )); + + when(mockSecretStore.getSecret(eq("jwt.signing.key"))).thenReturn(SECRET_VALUE.getBytes()); + when(mockSecretStore.getSecret(eq("jwt.encryption.key"))).thenReturn(SECRET_VALUE.getBytes()); + + JwtBuilderFilter filter = (JwtBuilderFilter) new JwtBuilderFilter + .Heaplet().create(Name.of("this"), config, heap); + + assertThat(filter).isNotNull(); + } + + @Test(dataProvider = "signatureAlgorithms") + public void testSignature(String algorithm, byte[] secret) throws HeapException { + + + JsonValue config = json(object( + field("template", + object()), + field("signature", + object( + field("secretId", "jwt.signing.key"), + field("algorithm", algorithm) + )) + )); + + when(mockSecretStore.getSecret(anyString())).thenReturn(secret); + + JwtBuilderFilter filter = (JwtBuilderFilter) new JwtBuilderFilter + .Heaplet().create(Name.of("this"), config, heap); + + filter.filter(new RootContext(), request, mockHandler); + + ArgumentCaptor contextCaptor = ArgumentCaptor.forClass(JwtBuilderContext.class); + verify(mockHandler).handle(contextCaptor.capture(), eq(request)); + + JwtBuilderContext jwtContext = contextCaptor.getValue(); + assertThat(jwtContext).isNotNull(); + assertThat(jwtContext.getValue()).startsWith("eyJ"); + } + + @Test(dataProvider = "encryptionAlgorithms") + public void testEncryption(String algorithm, String method, byte[] encryptionKey) throws HeapException { + JsonValue config = json(object( + field("template", + object(field("sub", "${attributes.subject}"))), + field("signature", + object( + field("secretId", "jwt.signing.key"), + field("algorithm", "HS256") + )), + field("encryption", + object( + field("secretId", "jwt.encryption.key"), + field("method", method), + field("algorithm", algorithm) + )) + )); + + + when(mockSecretStore.getSecret(eq("jwt.signing.key"))).thenReturn(SECRET_VALUE.getBytes()); + when(mockSecretStore.getSecret(eq("jwt.encryption.key"))).thenReturn(encryptionKey); + + JwtBuilderFilter filter = (JwtBuilderFilter) new JwtBuilderFilter + .Heaplet().create(Name.of("this"), config, heap); + + filter.filter(new RootContext(), request, mockHandler); + + ArgumentCaptor contextCaptor = ArgumentCaptor.forClass(JwtBuilderContext.class); + verify(mockHandler).handle(contextCaptor.capture(), eq(request)); + + JwtBuilderContext jwtContext = contextCaptor.getValue(); + assertThat(jwtContext).isNotNull(); + assertThat(jwtContext.getValue()).startsWith("eyJ"); + + } + + + @Test + public void testClaims() throws HeapException { + + JsonValue config = json(object( + field("template", + object(field("sub", "${attributes.subject}"), + field("exp", "${now.plusSeconds(20).epochSeconds}"))) + )); + + AttributesContext attributesContext = new AttributesContext(new RootContext()); + attributesContext.getAttributes().put("subject", "user1"); + + JwtBuilderFilter filter = (JwtBuilderFilter) new JwtBuilderFilter + .Heaplet().create(Name.of("this"), config, heap); + + + filter.filter(attributesContext, request, mockHandler); + + ArgumentCaptor contextCaptor = ArgumentCaptor.forClass(JwtBuilderContext.class); + verify(mockHandler).handle(contextCaptor.capture(), eq(request)); + + JwtBuilderContext jwtContext = contextCaptor.getValue(); + assertThat(jwtContext).isNotNull(); + assertThat(jwtContext.getClaims()).containsKey("sub").containsKey("exp"); + assertThat(jwtContext.getClaims().get("sub")).isEqualTo("user1"); + assertThat(jwtContext.getClaims().get("exp")).isInstanceOf(Long.class); + } + + @Test(expectedExceptions = HeapException.class) + public void testSignatureError() throws HeapException { + JsonValue config = json(object( + field("template", + object(field("sub", "${attributes.subject}"))), + field("signature", + object( + field("secretId", "jwt.signing.key"), + field("algorithm", "RS256") + )))); + + when(mockSecretStore.getSecret(eq("jwt.signing.key"))).thenReturn("bad_key".getBytes()); + new JwtBuilderFilter + .Heaplet().create(Name.of("this"), config, heap); + + } + + + @DataProvider(name = "signatureAlgorithms") + public Object[][] signatureAlgorithms() throws NoSuchAlgorithmException, InvalidAlgorithmParameterException { + + byte[] hmacSecret = SECRET_VALUE.getBytes(StandardCharsets.UTF_8); + + KeyPairGenerator rsaGen = KeyPairGenerator.getInstance("RSA"); + rsaGen.initialize(2048); + KeyPair rsaKeyPair = rsaGen.generateKeyPair(); + byte[] rsaPrivateKeyBytes = rsaKeyPair.getPrivate().getEncoded(); + + // EC key pair + KeyPairGenerator ecGen = KeyPairGenerator.getInstance("EC"); + ecGen.initialize(new ECGenParameterSpec("secp256r1")); + KeyPair ecKeyPair = ecGen.generateKeyPair(); + byte[] ecPrivateKeyBytes = ecKeyPair.getPrivate().getEncoded(); + + return new Object[][] { + {"HS256", hmacSecret}, + {"HS384", hmacSecret}, + {"HS512", hmacSecret}, + {"RS256", rsaPrivateKeyBytes}, + {"RS384", rsaPrivateKeyBytes}, + {"RS512", rsaPrivateKeyBytes}, + {"ES256", ecPrivateKeyBytes}, + {"ES384", ecPrivateKeyBytes}, + {"ES512", ecPrivateKeyBytes} + }; + } + + @DataProvider(name = "encryptionAlgorithms") + public Object[][] encryptionAlgorithms() throws NoSuchAlgorithmException { + KeyPairGenerator rsaGen = KeyPairGenerator.getInstance("RSA"); + rsaGen.initialize(2048); + KeyPair rsaKeyPair = rsaGen.generateKeyPair(); + byte[] rsaPublicKeyBytes = rsaKeyPair.getPublic().getEncoded(); + + + // AES keys + KeyGenerator aesGen = KeyGenerator.getInstance("AES"); + aesGen.init(128); + byte[] aes128Key = aesGen.generateKey().getEncoded(); + aesGen.init(256); + byte[] aes256Key = aesGen.generateKey().getEncoded(); + + return new Object[][]{ + {"RSA1_5", "A128CBC-HS256", rsaPublicKeyBytes}, + {"RSA-OAEP", "A192CBC-HS384", rsaPublicKeyBytes}, + {"RSA-OAEP-256", "A256CBC-HS512", rsaPublicKeyBytes}, + {"dir", "A128GCM", aes128Key}, + {"A128KW", "A192GCM", aes128Key}, + {"A192KW", "A256GCM", aes128Key}, + {"A256KW", "A256GCM", aes256Key} + }; + } +} \ No newline at end of file diff --git a/openig-core/src/test/java/org/openidentityplatform/openig/secrets/SystemAndEnvSecretStoreTest.java b/openig-core/src/test/java/org/openidentityplatform/openig/secrets/SystemAndEnvSecretStoreTest.java new file mode 100644 index 000000000..aca99588d --- /dev/null +++ b/openig-core/src/test/java/org/openidentityplatform/openig/secrets/SystemAndEnvSecretStoreTest.java @@ -0,0 +1,76 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Copyright 2026 3A Systems LLC. + */ + +package org.openidentityplatform.openig.secrets; + +import org.forgerock.json.JsonValue; +import org.forgerock.openig.heap.HeapException; +import org.forgerock.openig.heap.HeapImpl; +import org.forgerock.openig.heap.Name; +import org.testng.annotations.Test; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.forgerock.json.JsonValue.field; +import static org.forgerock.json.JsonValue.json; +import static org.forgerock.json.JsonValue.object; + +public class SystemAndEnvSecretStoreTest { + + final static String SECRET_VALUE = "s3cr3t"; + + + @Test(description = "Should retrieve secret from system property in plain format") + public void testGetSecretFromSystemPropertyPlain() { + + System.setProperty("TEST_SECRET", SECRET_VALUE); + + SystemAndEnvSecretStore store = new SystemAndEnvSecretStore(SystemAndEnvSecretStore.Format.PLAIN); + + byte[] result = store.getSecret("test.secret"); + + assertThat(result).isNotEmpty(); + assertThat(SECRET_VALUE).isEqualTo(new String(result, StandardCharsets.UTF_8)); + } + + @Test + public void testGetSecretFromSystemPropertyBase64() { + String base64Value = Base64.getEncoder().encodeToString(SECRET_VALUE.getBytes(StandardCharsets.UTF_8)); + System.setProperty("API_KEY", base64Value); + + SystemAndEnvSecretStore store = new SystemAndEnvSecretStore(SystemAndEnvSecretStore.Format.BASE64); + + byte[] result = store.getSecret("api.key"); + + assertThat(result).isNotEmpty(); + assertThat(SECRET_VALUE).isEqualTo(new String(result, StandardCharsets.UTF_8)); + } + + @Test + public void createSecretStoreFromHeapTest() throws HeapException { + + HeapImpl heap = new HeapImpl(Name.of("anonymous")); + JsonValue config = json(object(field("format", "PLAIN"))); + SystemAndEnvSecretStore secrets = (SystemAndEnvSecretStore) + new SystemAndEnvSecretStore.Heaplet() + .create(Name.of("this"), config, heap); + + assertThat(secrets).isNotNull(); + } + +} \ No newline at end of file diff --git a/openig-doc/src/main/asciidoc/reference/filters-conf.adoc b/openig-doc/src/main/asciidoc/reference/filters-conf.adoc index 705b4c842..3e349e5ed 100644 --- a/openig-doc/src/main/asciidoc/reference/filters-conf.adoc +++ b/openig-doc/src/main/asciidoc/reference/filters-conf.adoc @@ -12,7 +12,7 @@ information: "Portions copyright [year] [name of copyright owner]". Copyright 2017 ForgeRock AS. - Portions Copyright 2024-2025 3A Systems LLC. + Portions Copyright 2024-2026 3A Systems LLC. //// :figure-caption!: @@ -570,6 +570,118 @@ See also xref:expressions-conf.adoc#Expressions[Expressions(5)]. ==== Javadoc link:{apidocs-url}/index.html?org/forgerock/openig/filter/FileAttributesFilter.html[org.forgerock.openig.filter.FileAttributesFilter, window=\_blank] +''' +[#JwtBuilderFilter] +=== JwtBuilderFilter — remove and add headers + +Creates a JSON Web Token (JWT) with runtime data and puts the JWT into the link:{apidocs-url}/org/openidentityplatform/openig/filter/JwtBuilderContext.java[JwtBuilderContext, window=\_blank] + +Configure JwtBuilderFilter create a signed JWT or a signed then encrypted JWT. + +To retrieve secrets from environment or system properties, the xref:misc-conf.adoc#SystemAndEnvSecretStore[SystemAndEnvSecretStore] object should be declared in a heap. + +==== Usage + +[source, json] +---- +{ + "name": string, + "type": "JwtBuilderFilter", + "config": { + "template": map or runtime expression, + "signature": { + "secretId": configuration expression, + "algorithm": configuration expression + }, + "encryption": { + "secretId": configuration expression, + "algorithm": configuration expression, + "method": configuration expression + } + } +} +---- + +==== Properties + +`"template"`: __map or runtime expression, required__:: ++ +[open] +==== +A map of one or more data pairs with the format `Map`, where: + +* The key is the name of a data field + +* The value is a data object, or a runtime expression that evaluates to a data object +==== + + +`"signature"`: object, optional:: ++ +[open] +==== +A JWT signature to allow the authenticity of the claims/data to be validated. A signed JWT can be encrypted. + +The encryption settings take precedence over this property. + +`"secretId"`: configuration expression, required if signature is used:: +The secret ID of the key to sign the JWT. + +`"algorithm"`: configuration expression, optional:: ++ +The algorithm to sign the JWT. ++ +Default: RS256 +==== + +`"encryption"`: object, optional:: ++ +[open] +==== +Configuration to encrypt the JWT. + +This property takes precedence over the signature settings. + +`"secretId"`: string, optional:: ++ +The secret ID of the key used to encrypt the JWT. + + +`"algorithm"`: configuration expression, required:: +The algorithm used to encrypt the JWT. + +`"method"`: configuration expression, required:: +The method used to encrypt the JWT. +==== + +==== Example + +[source, json] +---- +{ + "type": "JwtBuilderFilter", + "config": { + "template": { + "sub": "${request.headers['X-User-ID'][0]}", + "exp": "${now.plusSeconds(20).epochSeconds}", + "iss": "my-issuer" + }, + "signature": { + "algorithm": "RS256", + "secretId": "jwt.signing.key" + }, + "encryption": { + "algorithm": "RSA-OAEP", + "method": "A128GCM", + "secretId": "jwt.encryption.key" + } + } +} +---- + +==== Javadoc +link:{apidocs-url}/org/openidentityplatform/openig/filter/JwtBuilderFilter.html[org.openidentityplatform.openig.filter.JwtBuilderFilter, window=\_blank] + ''' [#HeaderFilter] === HeaderFilter — remove and add headers diff --git a/openig-doc/src/main/asciidoc/reference/misc-conf.adoc b/openig-doc/src/main/asciidoc/reference/misc-conf.adoc index b9e84e75d..97bd1c009 100644 --- a/openig-doc/src/main/asciidoc/reference/misc-conf.adoc +++ b/openig-doc/src/main/asciidoc/reference/misc-conf.adoc @@ -12,7 +12,7 @@ information: "Portions copyright [year] [name of copyright owner]". Copyright 2017 ForgeRock AS. - Portions Copyright 2024-2025 3A Systems LLC. + Portions Copyright 2024-2026 3A Systems LLC. //// :figure-caption!: @@ -712,6 +712,52 @@ The following example creates a thread pool to execute tasks. When the executor ==== Javadoc link:{apidocs-url}/index.html?org/forgerock/openig/thread/ScheduledExecutorServiceHeaplet.html[org.forgerock.openig.thread.ScheduledExecutorServiceHeaplet, window=\_blank] +''' +[#SystemAndEnvSecretStore] +=== SystemAndEnvSecretStore — retrieve secrets from system properties and environment variables + +==== Usage + +[source, json] +---- +{ + "type": "SystemAndEnvSecretStore", + "config": { + "format": string or expression + } +} +---- + +==== Properties + + +`"format"`: __string, optional__:: +[open] +==== +Format in which the secret is stored. Use one of the following values: + +* BASE64: Base64-encoded +* PLAIN: Plain text + +Default: BASE64 +==== + +==== Example + +[source, json] +---- +{ + "type": "SystemAndEnvSecretStore", + "config": { + "format": "BASE64" + } +} +---- + +==== Javadoc +link:{apidocs-url}/org/openidentityplatform/openig/secrets/SystemAndEnvSecretStore.html[org.openidentityplatform.openig.secrets.SystemAndEnvSecretStore, window=\_blank] + + ''' [#TemporaryStorage] === TemporaryStorage — cache streamed content diff --git a/openig-openam/src/main/java/org/forgerock/openig/openam/PolicyEnforcementFilter.java b/openig-openam/src/main/java/org/forgerock/openig/openam/PolicyEnforcementFilter.java index 1c94dd3c2..fc9c48139 100644 --- a/openig-openam/src/main/java/org/forgerock/openig/openam/PolicyEnforcementFilter.java +++ b/openig-openam/src/main/java/org/forgerock/openig/openam/PolicyEnforcementFilter.java @@ -12,6 +12,7 @@ * information: "Portions copyright [year] [name of copyright owner]". * * Copyright 2015-2016 ForgeRock AS. + * Portions copyright 2026 3A Systems LLC. */ package org.forgerock.openig.openam; @@ -36,7 +37,7 @@ import static org.forgerock.openig.heap.Keys.FORGEROCK_CLIENT_HANDLER_HEAP_KEY; import static org.forgerock.openig.heap.Keys.SCHEDULED_EXECUTOR_SERVICE_HEAP_KEY; import static org.forgerock.openig.heap.Keys.TIME_SERVICE_HEAP_KEY; -import static org.forgerock.openig.util.JsonValues.evaluated; +import static org.forgerock.openig.util.JsonValues.asFunction; import static org.forgerock.openig.util.JsonValues.optionalHeapObject; import static org.forgerock.openig.util.JsonValues.requiredHeapObject; import static org.forgerock.openig.util.JsonValues.slashEnded; @@ -59,9 +60,7 @@ import org.forgerock.http.protocol.Request; import org.forgerock.http.protocol.Response; import org.forgerock.http.protocol.Responses; -import org.forgerock.json.JsonException; import org.forgerock.json.JsonValue; -import org.forgerock.json.JsonValueException; import org.forgerock.json.resource.ActionRequest; import org.forgerock.json.resource.ActionResponse; import org.forgerock.json.resource.FilterChain; @@ -77,7 +76,6 @@ import org.forgerock.openig.handler.Handlers; import org.forgerock.openig.heap.GenericHeaplet; import org.forgerock.openig.heap.HeapException; -import org.forgerock.openig.util.JsonValues; import org.forgerock.services.context.Context; import org.forgerock.util.AsyncFunction; import org.forgerock.util.Function; @@ -625,55 +623,6 @@ private Function>, ExpressionException> envir return asFunction(config.get("environment"), (Class>) (Class) List.class, heapBindings); } - @VisibleForTesting - static Function, ExpressionException> - asFunction(final JsonValue node, final Class expectedType, final Bindings initialBindings) { - if (node.isNull()) { - return null; - } else if (node.isString()) { - return new Function, ExpressionException>() { - - @SuppressWarnings("unchecked") - @Override - public Map apply(Bindings bindings) throws ExpressionException { - return node.as(JsonValues.expression(Map.class, initialBindings)).eval(bindings); - } - }; - } else if (node.isMap()) { - return new Function, ExpressionException>() { - // Avoid 'environment' entry's value to be null (cause an error in AM) - Function filterNullMapValues = - new Function() { - - @Override - public JsonValue apply(JsonValue value) { - if (!value.isMap()) { - return value; - } - - Map object = object(); - for (String key : value.keys()) { - JsonValue entry = value.get(key); - if (entry.isNotNull()) { - object.put(key, entry.getObject()); - } - } - return new JsonValue(object, value.getPointer()); - } - }; - - @Override - public Map apply(Bindings bindings) throws ExpressionException { - return node.as(evaluated(bindings)) - .as(filterNullMapValues) // see OPENIG-1402 and AME-12483 - .asMap(expectedType); - } - }; - } else { - throw new JsonValueException(node, "Expecting a String or a Map"); - } - } - @VisibleForTesting static URI normalizeToJsonEndpoint(final URI openamUri, final String realm) { final StringBuilder builder = new StringBuilder("json"); diff --git a/openig-openam/src/test/java/org/forgerock/openig/openam/PolicyEnforcementFilterTest.java b/openig-openam/src/test/java/org/forgerock/openig/openam/PolicyEnforcementFilterTest.java index 99c897949..31a0ffaf8 100644 --- a/openig-openam/src/test/java/org/forgerock/openig/openam/PolicyEnforcementFilterTest.java +++ b/openig-openam/src/test/java/org/forgerock/openig/openam/PolicyEnforcementFilterTest.java @@ -12,6 +12,7 @@ * information: "Portions Copyright [year] [name of copyright owner]". * * Copyright 2015-2016 ForgeRock AS. + * Portions Copyright 2026 3A Systems LLC. */ package org.forgerock.openig.openam; @@ -38,13 +39,11 @@ import static org.forgerock.openig.heap.Keys.SCHEDULED_EXECUTOR_SERVICE_HEAP_KEY; import static org.forgerock.openig.heap.Keys.TEMPORARY_STORAGE_HEAP_KEY; import static org.forgerock.openig.openam.PolicyEnforcementFilter.CachePolicyDecisionFilter.createKeyCache; -import static org.forgerock.openig.openam.PolicyEnforcementFilter.Heaplet.asFunction; import static org.forgerock.openig.openam.PolicyEnforcementFilter.Heaplet.normalizeToJsonEndpoint; import static org.forgerock.util.time.Duration.duration; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.ArgumentMatchers.nullable; import static org.mockito.ArgumentMatchers.same; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; @@ -79,7 +78,6 @@ import org.forgerock.services.context.AttributesContext; import org.forgerock.services.context.Context; import org.forgerock.services.context.RootContext; -import org.forgerock.util.Function; import org.forgerock.util.promise.Promise; import org.forgerock.util.time.TimeService; import org.mockito.ArgumentCaptor; @@ -605,45 +603,7 @@ public void shouldSucceedToBuildApplicationResource(final String value) throws E assertThat(resources.get("application").asString()).isNotEmpty(); } - @SuppressWarnings("rawtypes") - @Test - public void shouldReturnNullWhenJsonValueInputIsNull() throws Exception { - Function, ExpressionException> function = - asFunction(json(null), List.class, bindings()); - assertThat(function).isNull(); - } - @DataProvider - public static Object[][] asFunctionProvider() { - //@Checkstyle:off - return new Object[][]{ - { - json("${foo}"), - object(field("bar", array(1, 2, 3))) - }, - { - json(object(field("quix", "${foo['bar']}"))), - object(field("quix", array(1, 2, 3))) - }, - { - // keys with null values will be dropped - json(object(field("foo", null), field("bar", array("quix")))), - object(field("bar", array("quix"))) - } - }; - //@Checkstyle:on - } - - @SuppressWarnings("rawtypes") - @Test(dataProvider = "asFunctionProvider") - public void shouldEvaluateToMap(JsonValue input, Map expectedOutput) throws Exception { - Bindings bindings = bindings().bind("foo", object(field("bar", array(1, 2, 3)))); - - Function, ExpressionException> function = - asFunction(input, List.class, bindings()); - - assertThat(function.apply(bindings)).isEqualTo(expectedOutput); - } private static void assertResourcesContainResourceURIAndSsoToken(final JsonValue resources) { assertResourcesContainResourceURIAndSubjects(resources, "ssoToken");