From 4d7554a2e43e85b703cfea54897e1d1cadb077fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Vav=C5=99=C3=ADk?= Date: Thu, 20 Aug 2026 12:22:31 +0200 Subject: [PATCH] fix(core): make TokenUtil Jackson-agnostic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit closes: https://github.com/keycloak/keycloak/issues/51884 Signed-off-by: Michal Vavřík --- .../java/org/keycloak/util/TokenUtil.java | 4 +- .../json/JacksonAgnosticClassesTest.java | 129 ++++++++++++++++++ .../JacksonAgnosticRepresentationsTest.java | 92 ------------- 3 files changed, 132 insertions(+), 93 deletions(-) create mode 100644 core/src/test/java/org/keycloak/json/JacksonAgnosticClassesTest.java delete mode 100644 core/src/test/java/org/keycloak/json/JacksonAgnosticRepresentationsTest.java diff --git a/core/src/main/java/org/keycloak/util/TokenUtil.java b/core/src/main/java/org/keycloak/util/TokenUtil.java index 10ebd7671f46..7ffb94287022 100644 --- a/core/src/main/java/org/keycloak/util/TokenUtil.java +++ b/core/src/main/java/org/keycloak/util/TokenUtil.java @@ -32,6 +32,7 @@ import org.keycloak.jose.jwe.enc.JWEEncryptionProvider; import org.keycloak.jose.jws.JWSInput; import org.keycloak.jose.jws.JWSInputException; +import org.keycloak.json.KeycloakJsonMapper; import org.keycloak.json.KeycloakJsonMapperFactory; import org.keycloak.representations.AccessToken; import org.keycloak.representations.JsonWebToken; @@ -292,8 +293,9 @@ public static void convertTokenRolesFromOtherClaims(AccessToken token) { } private static AccessToken.Access convertToAccess(Object accessClaim, String claimName) { + KeycloakJsonMapper jsonMapper = KeycloakJsonMapperFactory.mapper(); try { - return JsonSerialization.readValue(JsonSerialization.writeValueAsString(accessClaim), AccessToken.Access.class); + return jsonMapper.readValue(jsonMapper.writeValueAsString(accessClaim), AccessToken.Access.class); } catch (IOException ioe) { logger.warnf( "Failed to convert roles from claim %s. Ignoring", claimName); return null; diff --git a/core/src/test/java/org/keycloak/json/JacksonAgnosticClassesTest.java b/core/src/test/java/org/keycloak/json/JacksonAgnosticClassesTest.java new file mode 100644 index 000000000000..a820bb7b2d0c --- /dev/null +++ b/core/src/test/java/org/keycloak/json/JacksonAgnosticClassesTest.java @@ -0,0 +1,129 @@ +package org.keycloak.json; + +import java.io.IOException; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import org.keycloak.util.JsonSerialization; + +import org.junit.Test; + +import static org.junit.Assert.assertTrue; + +/** + * Ensures that representation classes in {@code org.keycloak.representations} and classes used by the representation + * do not use Jackson 2 specific types ({@code com.fasterxml.jackson.databind} or {@code com.fasterxml.jackson.core}) + * or {@link JsonSerialization}. + *

+ * Only shared annotations from {@code com.fasterxml.jackson.annotation} are allowed. + * Custom serializer classes that must extend Jackson base classes are allowlisted. + */ +public class JacksonAgnosticClassesTest { + + private static final Set FORBIDDEN_IMPORTS = new HashSet<>(Arrays.asList( + "com.fasterxml.jackson.databind", + "com.fasterxml.jackson.core", + JsonSerialization.class.getName() + )); + + private static final Set ALLOWLISTED_FILES = new HashSet<>(Arrays.asList( + "MultivaluedHashMapValueSerializer.java", + "MultivaluedHashMapValueDeserializer.java" + )); + + @Test + public void representationsMustNotUseJackson2() throws IOException { + Path representationsDir = findPackageDir("representations"); + List violations = new ArrayList<>(); + + collectClassesUsingJackson2(representationsDir, violations); + + assertTrue( + "Representation classes must not use Jackson 2 specific types " + + "(" + String.join(" or ", FORBIDDEN_IMPORTS) + "). " + + "Use org.keycloak.json abstractions instead (e.g., @StringOrArray, RawJsonValue). " + + "Violations:\n" + String.join("\n", violations), + violations.isEmpty() + ); + } + + @Test + public void tokenUtilMustNotUseJackson2() throws IOException { + Path utilDir = findPackageDir("util"); + List violations = new ArrayList<>(); + + collectClassesUsingJackson2(utilDir, violations, "JsonSerialization"); + + List tokenUtilViolations = violations.stream().filter(v -> v.contains("util/TokenUtil.java")).collect(Collectors.toList()); + + assertTrue( + "TokenUtil class must not import Jackson 2 specific types " + + "(" + String.join(" or ", FORBIDDEN_IMPORTS) + "). " + + "Use org.keycloak.json abstractions instead (e.g., @StringOrArray, RawJsonValue). " + + "Violations:\n" + String.join("\n", tokenUtilViolations), + tokenUtilViolations.isEmpty() + ); + } + + private static void collectClassesUsingJackson2(Path classesDir, List violations, + String... forbiddenClassesInSamePackage) throws IOException { + Files.walkFileTree(classesDir, new SimpleFileVisitor() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { + if (!file.toString().endsWith(".java")) { + return FileVisitResult.CONTINUE; + } + String fileName = file.getFileName().toString(); + if (ALLOWLISTED_FILES.contains(fileName)) { + return FileVisitResult.CONTINUE; + } + + List lines = Files.readAllLines(file); + for (int i = 0; i < lines.size(); i++) { + String line = lines.get(i); + if (!line.startsWith("import ")) { + for (String forbiddenClass : forbiddenClassesInSamePackage) { + if (line.contains(forbiddenClass)) { + addViolation(file, i, line, classesDir, violations); + } + } + } else { + for (String forbidden : FORBIDDEN_IMPORTS) { + if (line.contains(forbidden)) { + addViolation(file, i, line, classesDir, violations); + } + } + } + } + return FileVisitResult.CONTINUE; + } + }); + } + + private static void addViolation(Path file, int i, String line, Path representationsDir, List violations) { + String relative = representationsDir.getParent().relativize(file).toString(); + violations.add(relative + ":" + (i + 1) + " — " + line.trim()); + } + + private static Path findPackageDir(String subPackage) { + Path dir = Paths.get("core/src/main/java/org/keycloak/" + subPackage); + if (Files.isDirectory(dir)) { + return dir; + } + dir = Paths.get("src/main/java/org/keycloak/" + subPackage); + if (Files.isDirectory(dir)) { + return dir; + } + throw new IllegalStateException("Cannot find " + subPackage + " directory"); + } +} diff --git a/core/src/test/java/org/keycloak/json/JacksonAgnosticRepresentationsTest.java b/core/src/test/java/org/keycloak/json/JacksonAgnosticRepresentationsTest.java deleted file mode 100644 index 7c803f52f41d..000000000000 --- a/core/src/test/java/org/keycloak/json/JacksonAgnosticRepresentationsTest.java +++ /dev/null @@ -1,92 +0,0 @@ -package org.keycloak.json; - -import java.io.IOException; -import java.nio.file.FileVisitResult; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.nio.file.SimpleFileVisitor; -import java.nio.file.attribute.BasicFileAttributes; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.HashSet; -import java.util.List; -import java.util.Set; - -import org.junit.Test; - -import static org.junit.Assert.assertTrue; - -/** - * Ensures that representation classes in {@code org.keycloak.representations} do not use - * Jackson 2 specific types ({@code com.fasterxml.jackson.databind} or {@code com.fasterxml.jackson.core}). - *

- * Only shared annotations from {@code com.fasterxml.jackson.annotation} are allowed. - * Custom serializer classes that must extend Jackson base classes are allowlisted. - */ -public class JacksonAgnosticRepresentationsTest { - - private static final Set FORBIDDEN_PACKAGES = new HashSet<>(Arrays.asList( - "com.fasterxml.jackson.databind", - "com.fasterxml.jackson.core" - )); - - private static final Set ALLOWLISTED_FILES = new HashSet<>(Arrays.asList( - "MultivaluedHashMapValueSerializer.java", - "MultivaluedHashMapValueDeserializer.java" - )); - - @Test - public void representationsMustNotUseJacksonDatabindOrCore() throws IOException { - Path representationsDir = findRepresentationsDir(); - List violations = new ArrayList<>(); - - Files.walkFileTree(representationsDir, new SimpleFileVisitor() { - @Override - public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { - if (!file.toString().endsWith(".java")) { - return FileVisitResult.CONTINUE; - } - String fileName = file.getFileName().toString(); - if (ALLOWLISTED_FILES.contains(fileName)) { - return FileVisitResult.CONTINUE; - } - - List lines = Files.readAllLines(file); - for (int i = 0; i < lines.size(); i++) { - String line = lines.get(i); - if (!line.startsWith("import ")) { - continue; - } - for (String forbidden : FORBIDDEN_PACKAGES) { - if (line.contains(forbidden)) { - String relative = representationsDir.getParent().relativize(file).toString(); - violations.add(relative + ":" + (i + 1) + " — " + line.trim()); - } - } - } - return FileVisitResult.CONTINUE; - } - }); - - assertTrue( - "Representation classes must not import Jackson 2 specific types " - + "(com.fasterxml.jackson.databind or com.fasterxml.jackson.core). " - + "Use org.keycloak.json abstractions instead (e.g., @StringOrArray, RawJsonValue). " - + "Violations:\n" + String.join("\n", violations), - violations.isEmpty() - ); - } - - private static Path findRepresentationsDir() { - Path dir = Paths.get("core/src/main/java/org/keycloak/representations"); - if (Files.isDirectory(dir)) { - return dir; - } - dir = Paths.get("src/main/java/org/keycloak/representations"); - if (Files.isDirectory(dir)) { - return dir; - } - throw new IllegalStateException("Cannot find representations directory"); - } -}