From aea6b2424282a5236903b7b6f1442af9eb1338ee Mon Sep 17 00:00:00 2001 From: Steven Hawkins Date: Fri, 22 May 2026 13:22:38 -0400 Subject: [PATCH 1/5] fix: allowing the general use of synthetic wildcards (#48223) also simplifying datasources logic closes: #48214 Signed-off-by: Steve Hawkins --- .../org/keycloak/config/DatabaseOptions.java | 141 +++--------------- .../keycloak/config/WildcardOptionsUtil.java | 23 --- .../keycloak/config/database/Database.java | 139 +++++++++++------ .../quarkus/deployment/KeycloakProcessor.java | 24 ++- .../QuarkusSingleProfileConfigResolver.java | 31 ++-- .../mappers/DatabasePropertyMappers.java | 141 ++++++------------ .../mappers/PropertyMappers.java | 15 +- .../mappers/TelemetryPropertyMappers.java | 23 +-- .../mappers/WildcardPropertyMapper.java | 2 +- .../config/WildcardOptionsUtilTest.java | 16 -- .../dist/CustomJpaEntityProviderDistTest.java | 2 +- ...est.testBootstrapAdminService.approved.txt | 6 +- ...stTest.testBootstrapAdminUser.approved.txt | 6 +- ...CommandDistTest.testBuildHelp.approved.txt | 8 +- ...ommandDistTest.testExportHelp.approved.txt | 6 +- ...andDistTest.testExportHelpAll.approved.txt | 6 +- ...ommandDistTest.testImportHelp.approved.txt | 6 +- ...andDistTest.testImportHelpAll.approved.txt | 6 +- ...mandDistTest.testStartDevHelp.approved.txt | 6 +- ...dDistTest.testStartDevHelpAll.approved.txt | 6 +- ...CommandDistTest.testStartHelp.approved.txt | 6 +- ...mandDistTest.testStartHelpAll.approved.txt | 6 +- ...tUpdateCompatibilityCheckHelp.approved.txt | 6 +- ...dateCompatibilityCheckHelpAll.approved.txt | 6 +- ...dateCompatibilityMetadataHelp.approved.txt | 6 +- ...eCompatibilityMetadataHelpAll.approved.txt | 6 +- 26 files changed, 239 insertions(+), 410 deletions(-) diff --git a/quarkus/config-api/src/main/java/org/keycloak/config/DatabaseOptions.java b/quarkus/config-api/src/main/java/org/keycloak/config/DatabaseOptions.java index bebc1f5220e..056ac931ff6 100644 --- a/quarkus/config-api/src/main/java/org/keycloak/config/DatabaseOptions.java +++ b/quarkus/config-api/src/main/java/org/keycloak/config/DatabaseOptions.java @@ -2,17 +2,10 @@ package org.keycloak.config; import java.io.File; import java.util.Arrays; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.function.Consumer; -import java.util.stream.Stream; import org.keycloak.config.database.Database; import static org.keycloak.config.OptionsUtil.DURATION_DESCRIPTION; -import static org.keycloak.config.WildcardOptionsUtil.getWildcardNamedKey; public class DatabaseOptions { @@ -27,12 +20,21 @@ public class DatabaseOptions { .description("The fully qualified class name of the JDBC driver. If not set, a default driver is set accordingly to the chosen database.") .buildTime(true) .build(); + + public static final Option DB_KIND = new OptionBuilder<>("db-kind-", String.class) + .category(OptionCategory.DATABASE_DATASOURCES) + .description("Used for named . The database vendor.") + .expectedValues(Database.getDatabaseAliases()) + .connectedOptions(TransactionOptions.TRANSACTION_XA_ENABLED_DATASOURCE) + .buildTime(true) + .build(); public static final Option DB = new OptionBuilder<>("db", String.class) .category(OptionCategory.DATABASE) .description("The database vendor. In production mode the default value of 'dev-file' is deprecated, you should explicitly specify the db instead.") .defaultValue("dev-file") .expectedValues(Database.getDatabaseAliases()) + .wildcardKey(DB_KIND.getKey()) .buildTime(true) .build(); @@ -40,6 +42,7 @@ public class DatabaseOptions { .category(OptionCategory.DATABASE) .description("The full database JDBC URL. If not provided, a default URL is set based on the selected database vendor. " + "For instance, if using 'postgres', the default JDBC URL would be 'jdbc:postgresql://localhost/keycloak'. ") + .wildcardKey("db-url-full-") .build(); public static final Option DB_URL_HOST = new OptionBuilder<>("db-url-host", String.class) @@ -165,57 +168,7 @@ public class DatabaseOptions { .description("The type of the keystore file. Common values include 'JKS' (Java KeyStore) and 'PKCS12'. If not specified, it uses the driver's default.") .build(); - public static final class Datasources { - /** - * Options that have their sibling for a named datasource - * Example: for `db-dialect`, `db-dialect-` is created - */ - public static final List OPTIONS_DATASOURCES = Stream.of( - DB_CONNECT_TIMEOUT, - DB_DIALECT, - DB_DRIVER, - DB, - DB_URL, - DB_URL_HOST, - DB_URL_DATABASE, - DB_URL_PORT, - DB_URL_PROPERTIES, - DB_USERNAME, - DB_PASSWORD, - DB_SCHEMA, - DB_POOL_INITIAL_SIZE, - DB_POOL_MIN_SIZE, - DB_POOL_MAX_SIZE, - DB_SQL_JPA_DEBUG, - DB_SQL_LOG_SLOW_QUERIES, - DB_TLS_MODE, - DB_TLS_TRUST_STORE_FILE, - DB_TLS_TRUST_STORE_PASSWORD, - DB_TLS_TRUST_STORE_TYPE, - DB_MTLS_KEY_STORE_FILE, - DB_MTLS_KEY_STORE_PASSWORD, - DB_MTLS_KEY_STORE_TYPE - ).map(Option::getKey).toList(); - - /** - * In order to avoid ambiguity, we need to have unique option names for wildcard options. - * This map controls overriding option name to be unique for wildcard option. - */ - private static final Map DATASOURCES_OVERRIDES_SUFFIX = Map.of( - DatabaseOptions.DB.getKey(), "-kind", // db-kind - DatabaseOptions.DB_URL.getKey(), "-full" // db-url-full - ); - - /** - * You can override some {@link OptionBuilder} methods for additional datasources in this map - */ - private static final Map, Consumer>> DATASOURCES_OVERRIDES_OPTIONS = Map.of( - DatabaseOptions.DB, builder -> builder - .defaultValue(Optional.empty()) // no default value for DB kind for datasources - .connectedOptions(TransactionOptions.TRANSACTION_XA_ENABLED_DATASOURCE) - ); - - private static final Map> cachedDatasourceOptions = new HashMap<>(); + public static class Datasources { /** * Get datasource option containing named datasource mapped to parent DB options. @@ -224,72 +177,22 @@ public class DatabaseOptions { *
    *
  • {@code db-url-host --> db-url-host-}
  • *
  • {@code db-username --> db-username-}
  • - *
  • {@code db --> db-kind-}
  • *
*/ @SuppressWarnings("unchecked") - public static Optional> getDatasourceOption(Option parentOption) { - if (!OPTIONS_DATASOURCES.contains(parentOption.getKey())) { - return Optional.empty(); + protected static Option getDatasourceOption(Option parentOption) { + var key = parentOption.getWildcardKey().orElse(parentOption.getKey().concat("-")); + var builder = parentOption.toBuilder() + .key(key) + .category(OptionCategory.DATABASE_DATASOURCES); + + if (!parentOption.isHidden()) { + builder.description("Used for named . " + parentOption.getDescription()); } - var key = getKeyForDatasource(parentOption); - if (key.isEmpty()) { - return Optional.empty(); - } - - // check if we already created the same option and return it from the cache - Option option = cachedDatasourceOptions.get(key.get()); - - if (option == null) { - var builder = parentOption.toBuilder() - .key(key.get()) - .category(OptionCategory.DATABASE_DATASOURCES); - - if (!parentOption.isHidden()) { - builder.description("Used for named . " + parentOption.getDescription()); - } - - // override some settings for options - var override = DATASOURCES_OVERRIDES_OPTIONS.get(parentOption); - if (override != null) { - override.accept(builder); - } - - option = builder.build(); - parentOption.setWildcardKey(option.getKey()); - cachedDatasourceOptions.put(key.get(), option); - } - return Optional.of((Option) option); - } - - /** - * Get mapped datasource key based on DB option {@param option} - */ - public static Optional getKeyForDatasource(Option option) { - return getKeyForDatasource(option.getKey()); - } - - /** - * Get mapped datasource key based on DB option {@param option} - */ - public static Optional getKeyForDatasource(String option) { - return Optional.of(option) - .filter(OPTIONS_DATASOURCES::contains) - .map(key -> key.concat(DATASOURCES_OVERRIDES_SUFFIX.getOrDefault(key, ""))) - .map(key -> key.concat("-")); - } - - /** - * Returns datasource option based on DB option {@code option} with actual wildcard value. - * It replaces the {@code } with actual value in {@code namedProperty}. - *

- * f.e. Consider {@code option}={@link DatabaseOptions#DB_DRIVER}, and {@code namedProperty}=my-store. - *

- * Result: {@code db-driver-my-store} - */ - public static Optional getNamedKey(Option option, String namedProperty) { - return getKeyForDatasource(option).map(key -> getWildcardNamedKey(key, namedProperty)); + Option option = builder.build(); + parentOption.setWildcardKey(option.getKey()); + return (Option)option; } } diff --git a/quarkus/config-api/src/main/java/org/keycloak/config/WildcardOptionsUtil.java b/quarkus/config-api/src/main/java/org/keycloak/config/WildcardOptionsUtil.java index d3216d763c3..994eed9cd3f 100644 --- a/quarkus/config-api/src/main/java/org/keycloak/config/WildcardOptionsUtil.java +++ b/quarkus/config-api/src/main/java/org/keycloak/config/WildcardOptionsUtil.java @@ -75,27 +75,4 @@ public class WildcardOptionsUtil { return prefix != null ? prefix.concat(value) : null; } - /** - * Extracts the name that replaces the wildcard placeholder from a fully qualified configuration key. - *

- * Examples: - *

{@code
-     * getWildcardValue(TracingOptions.TRACING_HEADER, "tracing-header-Authorization") → "Authorization"
-     * getWildcardValue(DatabaseOptions.DB_ENABLED_DATASOURCE, "db-enabled-my-store") → "my-store"
-     * getWildcardValue(DatabaseOptions.DB_ENABLED_DATASOURCE, "kc.db-enabled-my-store") → "my-store"
-     * }
- * - * @param option the option containing a wildcard key - * @param namedKey the fully qualified (resolved) configuration key - * @return the part of {@code namedKey} that replaces the wildcard in {@code option.getKey()}, otherwise {@code null} - */ - public static String getWildcardValue(Option option, String namedKey) { - if (option == null || namedKey == null) { - return null; - } - - String key = namedKey.startsWith("kc.") ? namedKey.substring("kc.".length()) : namedKey; - String prefix = getWildcardPrefix(option.getKey()); - return prefix != null && key.startsWith(prefix) ? key.substring(prefix.length()) : null; - } } diff --git a/quarkus/config-api/src/main/java/org/keycloak/config/database/Database.java b/quarkus/config-api/src/main/java/org/keycloak/config/database/Database.java index 5b9478de8de..8d85a48b702 100644 --- a/quarkus/config-api/src/main/java/org/keycloak/config/database/Database.java +++ b/quarkus/config-api/src/main/java/org/keycloak/config/database/Database.java @@ -24,11 +24,12 @@ import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Optional; -import java.util.function.BiFunction; import java.util.function.Function; import java.util.stream.Collectors; +import org.keycloak.common.util.TriFunction; import org.keycloak.config.DatabaseOptions; +import org.keycloak.config.DatabaseOptions.DatabaseTlsMode; import org.keycloak.config.Option; import io.quarkus.runtime.util.StringUtil; @@ -37,8 +38,6 @@ import static java.util.Arrays.asList; public final class Database { - public final static String ORACLE_URL_PREFIX = "jdbc:oracle:thin:@"; - private static final Map DATABASES = new HashMap<>(); static { @@ -72,8 +71,8 @@ public final class Database { /** * The {@param namedProperty} represents name of the named datasource if we need to set the URL for additional datasource */ - public static Optional getDefaultUrl(String namedProperty, String alias) { - return getVendor(alias).map(f -> f.defaultUrl.apply(namedProperty, alias)); + public static Optional getDefaultUrl(Function, String> getter, String namedProperty, String alias) { + return getVendor(alias).map(f -> f.defaultUrl.apply(getter, namedProperty, alias)); } public static Optional getDriver(String alias, boolean isXaEnabled) { @@ -88,7 +87,6 @@ public final class Database { return getVendor(alias).map(mapper); } - /** * @return List of aliases of databases */ @@ -104,12 +102,13 @@ public final class Database { "org.h2.jdbcx.JdbcDataSource", "org.h2.Driver", "org.hibernate.dialect.H2Dialect", - new BiFunction<>() { + new TriFunction<>() { @Override - public String apply(String namedProperty, String alias) { + public String apply(Function, String> getter, String namedProperty, String alias) { + String url; if ("dev-file".equalsIgnoreCase(alias)) { var separator = escapeReplacements(File.separator); - return new StringBuilder() + url = new StringBuilder() .append("jdbc:h2:file:") .append("${kc.db-url-path:${kc.home.dir:%s}}".formatted(escapeReplacements(System.getProperty("user.home")))) .append(separator) @@ -119,8 +118,14 @@ public final class Database { .append(separator) .append(getDbName(namedProperty)) .toString(); + } else { + url = "jdbc:h2:mem:%s".formatted(getDbName(namedProperty)); } - return "jdbc:h2:mem:%s".formatted(getDbName(namedProperty)); + String urlProps = getProperty(DatabaseOptions.DB_URL_PROPERTIES, getter); + if (!urlProps.isEmpty()) { + url += urlProps; + } + return amendH2(url); } private String getFolder(String namedProperty) { @@ -150,11 +155,11 @@ public final class Database { "com.mysql.cj.jdbc.Driver", "org.hibernate.dialect.MySQLDialect", // default URL looks like this: "jdbc:mysql://${kc.db-url-host:localhost}:${kc.db-url-port:3306}/${kc.db-url-database:keycloak}${kc.db-url-properties:}" - (namedProperty, alias) -> "jdbc:mysql://%s:%s/%s%s".formatted( - getProperty(DatabaseOptions.DB_URL_HOST, namedProperty, "localhost"), - getProperty(DatabaseOptions.DB_URL_PORT, namedProperty, "3306"), - getProperty(DatabaseOptions.DB_URL_DATABASE, namedProperty, "keycloak"), - getProperty(DatabaseOptions.DB_URL_PROPERTIES, namedProperty)), + (getter, namedProperty, alias) -> "jdbc:mysql://%s:%s/%s%s".formatted( + getProperty(DatabaseOptions.DB_URL_HOST, getter, "localhost"), + getProperty(DatabaseOptions.DB_URL_PORT, getter, "3306"), + getProperty(DatabaseOptions.DB_URL_DATABASE, getter, "keycloak"), + getProperty(DatabaseOptions.DB_URL_PROPERTIES, getter)), "org.keycloak.connections.jpa.updater.liquibase.UpdatedMySqlDatabase" ), TIDB("tidb", @@ -162,11 +167,11 @@ public final class Database { "com.mysql.cj.jdbc.Driver", "org.hibernate.community.dialect.TiDBDialect", // default URL looks like this: "jdbc:mysql://${kc.db-url-host:localhost}:${kc.db-url-port:3306}/${kc.db-url-database:keycloak}${kc.db-url-properties:}" - (namedProperty, alias) -> "jdbc:mysql://%s:%s/%s%s".formatted( - getProperty(DatabaseOptions.DB_URL_HOST, namedProperty, "localhost"), - getProperty(DatabaseOptions.DB_URL_PORT, namedProperty, "3306"), - getProperty(DatabaseOptions.DB_URL_DATABASE, namedProperty, "keycloak"), - getProperty(DatabaseOptions.DB_URL_PROPERTIES, namedProperty)), + (getter, namedProperty, alias) -> "jdbc:mysql://%s:%s/%s%s".formatted( + getProperty(DatabaseOptions.DB_URL_HOST, getter, "localhost"), + getProperty(DatabaseOptions.DB_URL_PORT, getter, "3306"), + getProperty(DatabaseOptions.DB_URL_DATABASE, getter, "keycloak"), + getProperty(DatabaseOptions.DB_URL_PROPERTIES, getter)), "org.keycloak.connections.jpa.updater.liquibase.UpdatedMySqlDatabase" ), MARIADB("mariadb", @@ -174,11 +179,11 @@ public final class Database { "org.mariadb.jdbc.Driver", "org.hibernate.dialect.MariaDBDialect", // default URL looks like this: "jdbc:mariadb://${kc.db-url-host:localhost}:${kc.db-url-port:3306}/${kc.db-url-database:keycloak}${kc.db-url-properties:}" - (namedProperty, alias) -> "jdbc:mariadb://%s:%s/%s%s".formatted( - getProperty(DatabaseOptions.DB_URL_HOST, namedProperty, "localhost"), - getProperty(DatabaseOptions.DB_URL_PORT, namedProperty, "3306"), - getProperty(DatabaseOptions.DB_URL_DATABASE, namedProperty, "keycloak"), - getProperty(DatabaseOptions.DB_URL_PROPERTIES, namedProperty)), + (getter, namedProperty, alias) -> "jdbc:mariadb://%s:%s/%s%s".formatted( + getProperty(DatabaseOptions.DB_URL_HOST, getter, "localhost"), + getProperty(DatabaseOptions.DB_URL_PORT, getter, "3306"), + getProperty(DatabaseOptions.DB_URL_DATABASE, getter, "keycloak"), + getProperty(DatabaseOptions.DB_URL_PROPERTIES, getter)), "org.keycloak.connections.jpa.updater.liquibase.UpdatedMariaDBDatabase" ), POSTGRES("postgresql", @@ -186,11 +191,11 @@ public final class Database { "org.postgresql.Driver", "org.hibernate.dialect.PostgreSQLDialect", // default URL looks like this: "jdbc:postgresql://${kc.db-url-host:localhost}:${kc.db-url-port:5432}/${kc.db-url-database:keycloak}${kc.db-url-properties:}" - (namedProperty, alias) -> "jdbc:postgresql://%s:%s/%s%s".formatted( - getProperty(DatabaseOptions.DB_URL_HOST, namedProperty, "localhost"), - getProperty(DatabaseOptions.DB_URL_PORT, namedProperty, "5432"), - getProperty(DatabaseOptions.DB_URL_DATABASE, namedProperty, "keycloak"), - getProperty(DatabaseOptions.DB_URL_PROPERTIES, namedProperty)), + (getter, namedProperty, alias) -> "jdbc:postgresql://%s:%s/%s%s".formatted( + getProperty(DatabaseOptions.DB_URL_HOST, getter, "localhost"), + getProperty(DatabaseOptions.DB_URL_PORT, getter, "5432"), + getProperty(DatabaseOptions.DB_URL_DATABASE, getter, "keycloak"), + getProperty(DatabaseOptions.DB_URL_PROPERTIES, getter)), "liquibase.database.core.PostgresDatabase", "postgres" ), @@ -199,11 +204,11 @@ public final class Database { "com.microsoft.sqlserver.jdbc.SQLServerDriver", "org.hibernate.dialect.SQLServerDialect", // default URL looks like this: "jdbc:sqlserver://${kc.db-url-host:localhost}:${kc.db-url-port:1433};databaseName=${kc.db-url-database:keycloak}${kc.db-url-properties:}" - (namedProperty, alias) -> "jdbc:sqlserver://%s:%s;databaseName=%s%s".formatted( - getProperty(DatabaseOptions.DB_URL_HOST, namedProperty, "localhost"), - getProperty(DatabaseOptions.DB_URL_PORT, namedProperty, "1433"), - getProperty(DatabaseOptions.DB_URL_DATABASE, namedProperty, "keycloak"), - getProperty(DatabaseOptions.DB_URL_PROPERTIES, namedProperty)), + (getter, namedProperty, alias) -> "jdbc:sqlserver://%s:%s;databaseName=%s%s".formatted( + getProperty(DatabaseOptions.DB_URL_HOST, getter, "localhost"), + getProperty(DatabaseOptions.DB_URL_PORT, getter, "1433"), + getProperty(DatabaseOptions.DB_URL_DATABASE, getter, "keycloak"), + getProperty(DatabaseOptions.DB_URL_PROPERTIES, getter)), "org.keycloak.quarkus.runtime.storage.database.liquibase.database.CustomMSSQLDatabase", "mssql" ), @@ -212,10 +217,12 @@ public final class Database { "oracle.jdbc.driver.OracleDriver", "org.hibernate.dialect.OracleDialect", // default URL looks like this: "jdbc:oracle:thin:@//${kc.db-url-host:localhost}:${kc.db-url-port:1521}/${kc.db-url-database:keycloak}" - (namedProperty, alias) -> ORACLE_URL_PREFIX + "//%s:%s/%s".formatted( - getProperty(DatabaseOptions.DB_URL_HOST, namedProperty, "localhost"), - getProperty(DatabaseOptions.DB_URL_PORT, namedProperty, "1521"), - getProperty(DatabaseOptions.DB_URL_DATABASE, namedProperty, "keycloak")), + (getter, namedProperty, alias) -> "jdbc:oracle:thin:%s//%s:%s/%s".formatted( + DatabaseOptions.DatabaseTlsMode.fromCliValue(getProperty(DatabaseOptions.DB_TLS_MODE, getter, + DatabaseOptions.DatabaseTlsMode.DISABLED.toCliValue())) == DatabaseTlsMode.DISABLED ? "@" : "@tcps:", + getProperty(DatabaseOptions.DB_URL_HOST, getter, "localhost"), + getProperty(DatabaseOptions.DB_URL_PORT, getter, "1521"), + getProperty(DatabaseOptions.DB_URL_DATABASE, getter, "keycloak")), "liquibase.database.core.OracleDatabase" ); @@ -223,16 +230,16 @@ public final class Database { final String xaDriver; final String nonXaDriver; final Function dialect; - final BiFunction defaultUrl; + final TriFunction, String>, String, String, String> defaultUrl; final String liquibaseType; final String[] aliases; - Vendor(String databaseKind, String xaDriver, String nonXaDriver, String dialect, BiFunction defaultUrl, + Vendor(String databaseKind, String xaDriver, String nonXaDriver, String dialect, TriFunction, String>, String, String, String> defaultUrl, String liquibaseType, String... aliases) { this(databaseKind, xaDriver, nonXaDriver, alias -> dialect, defaultUrl, liquibaseType, aliases); } - Vendor(String databaseKind, String xaDriver, String nonXaDriver, Function dialect, BiFunction defaultUrl, + Vendor(String databaseKind, String xaDriver, String nonXaDriver, Function dialect, TriFunction, String>, String, String, String> defaultUrl, String liquibaseType, String... aliases) { this.databaseKind = databaseKind; @@ -248,14 +255,12 @@ public final class Database { return databaseKind.equals(dbKind); } - private static String getProperty(Option option, String namedProperty) { - return getProperty(option, namedProperty, ""); + private static String getProperty(Option option, Function, String> getter) { + return getProperty(option, getter, ""); } - private static String getProperty(Option option, String namedProperty, String defaultValue) { - return "${kc.%s:%s}".formatted(StringUtil.isNullOrEmpty(namedProperty) ? option.getKey() : - DatabaseOptions.Datasources.getNamedKey(option, namedProperty).orElseThrow(() -> new IllegalArgumentException("Cannot find the named property")), - defaultValue); + private static String getProperty(Option option, Function, String> getter, String defaultValue) { + return Optional.ofNullable(getter.apply(option)).orElse(defaultValue); } public String getLiquibaseType() { @@ -266,5 +271,43 @@ public final class Database { public String toString() { return databaseKind.toLowerCase(Locale.ROOT); } + + /** + * Starting with H2 version 2.x, marking "VALUE" as a non-keyword is necessary as some columns are named "VALUE" in the Keycloak schema. + *

+ * Alternatives considered and rejected: + *

    + *
  • customizing H2 Database dialect -> wouldn't work for existing Liquibase scripts.
  • + *
  • adding quotes to @Column(name="VALUE") annotations -> would require testing for all DBs, wouldn't work for existing Liquibase scripts.
  • + *
+ * Downsides of this solution: Release notes needed to point out that any H2 JDBC URL parameter with NON_KEYWORDS needs to add the keyword VALUE manually. + * @return JDBC URL with NON_KEYWORDS=VALUE appended if the URL doesn't contain NON_KEYWORDS= yet + */ + private static String addH2NonKeywords(String jdbcUrl) { + if (!jdbcUrl.contains("NON_KEYWORDS=")) { + jdbcUrl = jdbcUrl + ";NON_KEYWORDS=VALUE"; + } + return jdbcUrl; + } + + /** + * Required so that the H2 db instance is closed only when the Agroal connection pool is closed during + * Keycloak shutdown. We cannot rely on the default H2 ShutdownHook as this can result in the DB being + * closed before dependent resources, e.g. JDBC_PING2, are shutdown gracefully. This solution also + * requires the Agroal min-pool connection size to be at least 1. + */ + private static String addH2CloseOnExit(String jdbcUrl) { + if (!jdbcUrl.contains("DB_CLOSE_ON_EXIT=")) { + jdbcUrl = jdbcUrl + ";DB_CLOSE_ON_EXIT=FALSE"; + } + if (!jdbcUrl.contains("DB_CLOSE_DELAY=")) { + jdbcUrl = jdbcUrl + ";DB_CLOSE_DELAY=0"; + } + return jdbcUrl; + } + + private static String amendH2(String jdbcUrl) { + return addH2CloseOnExit(addH2NonKeywords(jdbcUrl)); + } } } diff --git a/quarkus/deployment/src/main/java/org/keycloak/quarkus/deployment/KeycloakProcessor.java b/quarkus/deployment/src/main/java/org/keycloak/quarkus/deployment/KeycloakProcessor.java index 7b7569da780..bcaabbd57e9 100644 --- a/quarkus/deployment/src/main/java/org/keycloak/quarkus/deployment/KeycloakProcessor.java +++ b/quarkus/deployment/src/main/java/org/keycloak/quarkus/deployment/KeycloakProcessor.java @@ -92,6 +92,7 @@ import org.keycloak.quarkus.runtime.configuration.KeycloakConfigSourceProvider; import org.keycloak.quarkus.runtime.configuration.MicroProfileConfigProvider; import org.keycloak.quarkus.runtime.configuration.PersistedConfigSource; import org.keycloak.quarkus.runtime.configuration.PropertyMappingInterceptor; +import org.keycloak.quarkus.runtime.configuration.mappers.DatabasePropertyMappers; import org.keycloak.quarkus.runtime.configuration.mappers.PropertyMappers; import org.keycloak.quarkus.runtime.configuration.mappers.WildcardPropertyMapper; import org.keycloak.quarkus.runtime.integration.QuarkusKeycloakSessionFactory; @@ -417,7 +418,7 @@ class KeycloakProcessor { .filter(descriptor -> !descriptor.getName().equals(DEFAULT_PERSISTENCE_UNIT)) // not default persistence unit .map(KeycloakProcessor::getDatasourceNameFromPersistenceXml) .filter(this::missingDbKind) - .map(datasourceName -> DatabaseOptions.Datasources.getNamedKey(DatabaseOptions.DB, datasourceName).orElseThrow()).toList(); + .map(datasourceName -> PropertyMappers.getWildcardPropertyMapper(DatabaseOptions.DB_KIND).orElseThrow().getFrom(datasourceName)).toList(); if (!notSetPersistenceUnitsDBKinds.isEmpty()) { throwConfigError("Detected additional named datasources without a DB kind set, please specify: %s".formatted(String.join(",", notSetPersistenceUnitsDBKinds))); @@ -435,16 +436,15 @@ class KeycloakProcessor { * */ private boolean missingDbKind(String datasourceName) { - String key = NS_KEYCLOAK_PREFIX.concat(DatabaseOptions.Datasources.getNamedKey(DatabaseOptions.DB, datasourceName).orElseThrow()); PropertyMappingInterceptor.disable(); try { - var from = Configuration.getConfigValue(key); + var from = DatabasePropertyMappers.getDatasourceOptionValue(DB, datasourceName); - if (from.getValue() != null) { + if (from.isPresent()) { return false; // user has directly specified } - WildcardPropertyMapper mapper = (WildcardPropertyMapper)PropertyMappers.getMapper(key); + WildcardPropertyMapper mapper = PropertyMappers.getWildcardPropertyMapper(DatabaseOptions.DB_KIND).orElseThrow(); // quarkus properties boolean missing = Configuration.getOptionalValue(mapper.getTo(datasourceName)) @@ -578,13 +578,11 @@ class KeycloakProcessor { } // db-dialect - DatabaseOptions.Datasources.getNamedKey(DatabaseOptions.DB_DIALECT, datasourceName) - .flatMap(Configuration::getOptionalKcValue) + DatabasePropertyMappers.getDatasourceOptionValue(DatabaseOptions.DB_DIALECT, datasourceName) .ifPresent(dialect -> unitProperties.setProperty(AvailableSettings.DIALECT, dialect)); // db-schema - DatabaseOptions.Datasources.getNamedKey(DatabaseOptions.DB_SCHEMA, datasourceName) - .flatMap(Configuration::getOptionalKcValue) + DatabasePropertyMappers.getDatasourceOptionValue(DatabaseOptions.DB_SCHEMA, datasourceName) .ifPresent(schema -> unitProperties.setProperty(AvailableSettings.DEFAULT_SCHEMA, schema)); unitProperties.setProperty(AvailableSettings.JAKARTA_TRANSACTION_TYPE, PersistenceUnitTransactionType.JTA.name()); @@ -595,13 +593,11 @@ class KeycloakProcessor { unitProperties.setProperty(AvailableSettings.DATASOURCE, datasourceName); // for backward compatibility // db-debug-jpql - DatabaseOptions.Datasources.getNamedKey(DatabaseOptions.DB_SQL_JPA_DEBUG, datasourceName) - .filter(Configuration::isKcPropertyTrue) - .ifPresent(f -> unitProperties.put(AvailableSettings.USE_SQL_COMMENTS, "true")); + DatabasePropertyMappers.getDatasourceOptionValue(DatabaseOptions.DB_SQL_JPA_DEBUG, datasourceName) + .ifPresent(f -> unitProperties.put(AvailableSettings.USE_SQL_COMMENTS, f)); // db-log-slow-queries-threshold - DatabaseOptions.Datasources.getNamedKey(DatabaseOptions.DB_SQL_LOG_SLOW_QUERIES, datasourceName) - .flatMap(Configuration::getOptionalKcValue) + DatabasePropertyMappers.getDatasourceOptionValue(DatabaseOptions.DB_SQL_LOG_SLOW_QUERIES, datasourceName) .ifPresent(threshold -> unitProperties.put(AvailableSettings.LOG_SLOW_QUERY, threshold)); } diff --git a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/QuarkusSingleProfileConfigResolver.java b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/QuarkusSingleProfileConfigResolver.java index 6225070ac9e..4c138341677 100644 --- a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/QuarkusSingleProfileConfigResolver.java +++ b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/QuarkusSingleProfileConfigResolver.java @@ -5,9 +5,9 @@ import java.util.Map; import org.keycloak.common.profile.SingleProfileConfigResolver; import org.keycloak.config.FeatureOptions; -import org.keycloak.config.WildcardOptionsUtil; import org.keycloak.quarkus.runtime.cli.PropertyException; import org.keycloak.quarkus.runtime.configuration.Configuration; +import org.keycloak.quarkus.runtime.configuration.mappers.PropertyMappers; import static org.keycloak.quarkus.runtime.configuration.MicroProfileConfigProvider.NS_KEYCLOAK_PREFIX; @@ -19,23 +19,24 @@ public class QuarkusSingleProfileConfigResolver extends SingleProfileConfigResol protected static Map getQuarkusFeatureState() { var map = new HashMap(); - var featureEnabledOptionPrefix = NS_KEYCLOAK_PREFIX + WildcardOptionsUtil.getWildcardPrefix(FeatureOptions.FEATURE.getKey()); + var wildcard = PropertyMappers.getWildcardPropertyMapper(FeatureOptions.FEATURE).orElseThrow(); Configuration.getPropertyNames().forEach(property -> { - if (property.startsWith(NS_KEYCLOAK_PREFIX) && property.startsWith(featureEnabledOptionPrefix)) { - var feature = WildcardOptionsUtil.getWildcardValue(FeatureOptions.FEATURE, property); - var value = Configuration.getOptionalValue(property).orElseThrow( - () -> new PropertyException("Missing value for feature '%s'".formatted(feature))); + if (property.startsWith(NS_KEYCLOAK_PREFIX)) { + wildcard.extractWildcardValue(property).ifPresent(feature -> { + var value = Configuration.getOptionalValue(property).orElseThrow( + () -> new PropertyException("Missing value for feature '%s'".formatted(feature))); - if (value.startsWith("v")) { - map.put(feature + ":" + value, true); - } else { - map.put(feature, switch (value) { - case "enabled" -> Boolean.TRUE; - case "disabled" -> Boolean.FALSE; - default -> null; - }); - } + if (value.startsWith("v")) { + map.put(feature + ":" + value, true); + } else { + map.put(feature, switch (value) { + case "enabled" -> Boolean.TRUE; + case "disabled" -> Boolean.FALSE; + default -> null; + }); + } + }); } }); diff --git a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/mappers/DatabasePropertyMappers.java b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/mappers/DatabasePropertyMappers.java index 93d914b7fb3..31e045b411f 100644 --- a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/mappers/DatabasePropertyMappers.java +++ b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/mappers/DatabasePropertyMappers.java @@ -4,6 +4,7 @@ import java.time.Duration; import java.util.ArrayList; import java.util.Collection; import java.util.EnumSet; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; @@ -18,6 +19,7 @@ import org.keycloak.config.DatabaseOptions; import org.keycloak.config.Option; import org.keycloak.config.OptionsUtil; import org.keycloak.config.TransactionOptions; +import org.keycloak.config.WildcardOptionsUtil; import org.keycloak.config.database.Database; import org.keycloak.config.database.Database.Vendor; import org.keycloak.quarkus.runtime.cli.Picocli; @@ -32,6 +34,7 @@ import io.smallrye.config.ConfigValue; import org.jboss.logging.Logger; import static org.keycloak.config.DatabaseOptions.DB; +import static org.keycloak.config.DatabaseOptions.DB_KIND; import static org.keycloak.config.DatabaseOptions.DB_MTLS_KEY_STORE_FILE; import static org.keycloak.config.DatabaseOptions.DB_MTLS_KEY_STORE_PASSWORD; import static org.keycloak.config.DatabaseOptions.DB_MTLS_KEY_STORE_TYPE; @@ -42,10 +45,6 @@ import static org.keycloak.config.DatabaseOptions.DB_TLS_TRUST_STORE_FILE; import static org.keycloak.config.DatabaseOptions.DB_TLS_TRUST_STORE_PASSWORD; import static org.keycloak.config.DatabaseOptions.DB_TLS_TRUST_STORE_TYPE; import static org.keycloak.config.DatabaseOptions.DB_URL; -import static org.keycloak.config.DatabaseOptions.Datasources.OPTIONS_DATASOURCES; -import static org.keycloak.config.DatabaseOptions.Datasources.getDatasourceOption; -import static org.keycloak.config.DatabaseOptions.Datasources.getKeyForDatasource; -import static org.keycloak.config.DatabaseOptions.Datasources.getNamedKey; import static org.keycloak.quarkus.runtime.configuration.Configuration.getOptionalKcValue; import static org.keycloak.quarkus.runtime.configuration.MicroProfileConfigProvider.NS_KEYCLOAK_PREFIX; import static org.keycloak.quarkus.runtime.configuration.mappers.DatabasePropertyMappers.Datasources.appendDatasourceMappers; @@ -73,7 +72,7 @@ public final class DatabasePropertyMappers implements PropertyMapperGrouping { @Override public List> getPropertyMappers() { - List> mappers = List.of( + List> allSourceMappers = List.of( fromOption(DatabaseOptions.DB_DIALECT) .mapFrom(DB, DatabasePropertyMappers::transformDialect) .build(), @@ -82,11 +81,6 @@ public final class DatabasePropertyMappers implements PropertyMapperGrouping { .to("quarkus.datasource.jdbc.driver") .paramLabel("driver") .build(), - fromOption(DB) - .to("quarkus.datasource.db-kind") - .transformer(DatabasePropertyMappers::toDatabaseKind) - .paramLabel("vendor") - .build(), fromOption(DatabaseOptions.DB_URL) .to("quarkus.datasource.jdbc.url") .mapFrom(DB, DatabasePropertyMappers::getDatabaseUrl) @@ -159,19 +153,11 @@ public final class DatabasePropertyMappers implements PropertyMapperGrouping { .to("quarkus.datasource.jdbc.max-size") .paramLabel("size") .build(), - fromOption(DatabaseOptions.DB_POOL_MAX_LIFETIME) - .to("quarkus.datasource.jdbc.max-lifetime") - .mapFrom(DB, DatabasePropertyMappers::transformPoolMaxLifetime) - .paramLabel("duration") - .build(), fromOption(DatabaseOptions.DB_SQL_JPA_DEBUG) .build(), fromOption(DatabaseOptions.DB_SQL_LOG_SLOW_QUERIES) .paramLabel("milliseconds") .build(), - fromOption(DatabaseOptions.DB_ENABLED_DATASOURCE) - .to("quarkus.datasource.\"\".active") - .build(), // Database TLS configuration fromOption(DB_TLS_MODE) .paramLabel("mode") @@ -250,16 +236,33 @@ public final class DatabasePropertyMappers implements PropertyMapperGrouping { setInputTlsJdbcProperty(DB_MTLS_KEY_STORE_PASSWORD, "sslpassword", EnumSet.of(Database.Vendor.POSTGRES)) ); - List> result = appendDatasourceMappers(mappers, Map.of( + List> result = appendDatasourceMappers(allSourceMappers, Map.of( // Inherit options from the DB mappers - DB, PropertyMapper.Builder::removeMapFrom, DB_POOL_INITIAL_SIZE, mapper -> mapper.mapFrom(DB_POOL_INITIAL_SIZE), DB_POOL_MAX_SIZE, mapper -> mapper.mapFrom(DB_POOL_MAX_SIZE) )); - // finally add mappers that aren't intended to work with other datasources + // finally add mappers that aren't intended to work with all datasources // - also this usage of isEnabled won't work correctly with wildcard mappers result.addAll(List.of( + fromOption(DB) + .to("quarkus.datasource.db-kind") + .transformer(DatabasePropertyMappers::toDatabaseKind) + .paramLabel("vendor") + .build(), + fromOption(DB_KIND) + .to("quarkus.datasource.\"\".db-kind") + .transformer(DatabasePropertyMappers::toDatabaseKind) + .paramLabel("vendor") + .build(), + fromOption(DatabaseOptions.DB_POOL_MAX_LIFETIME) + .to("quarkus.datasource.jdbc.max-lifetime") + .mapFrom(DB, DatabasePropertyMappers::transformPoolMaxLifetime) + .paramLabel("duration") + .build(), + fromOption(DatabaseOptions.DB_ENABLED_DATASOURCE) + .to("quarkus.datasource.\"\".active") + .build(), fromOption(SYNTHETIC_RUNTIME_DB_OPTION).mapFrom(DB, (name, value, context) -> "primary") .to(PG_TARGET_SERVER_TYPE) .isEnabled(DatabasePropertyMappers::isPostgresqlTargetServerTypeEnabled) @@ -402,62 +405,8 @@ public final class DatabasePropertyMappers implements PropertyMapperGrouping { return String.valueOf(DurationConverter.parseDuration(value).toSeconds()); } - /** - * Starting with H2 version 2.x, marking "VALUE" as a non-keyword is necessary as some columns are named "VALUE" in the Keycloak schema. - *

- * Alternatives considered and rejected: - *

    - *
  • customizing H2 Database dialect -> wouldn't work for existing Liquibase scripts.
  • - *
  • adding quotes to @Column(name="VALUE") annotations -> would require testing for all DBs, wouldn't work for existing Liquibase scripts.
  • - *
- * Downsides of this solution: Release notes needed to point out that any H2 JDBC URL parameter with NON_KEYWORDS needs to add the keyword VALUE manually. - * @return JDBC URL with NON_KEYWORDS=VALUE appended if the URL doesn't contain NON_KEYWORDS= yet - */ - private static String addH2NonKeywords(String jdbcUrl) { - if (!jdbcUrl.contains("NON_KEYWORDS=")) { - jdbcUrl = jdbcUrl + ";NON_KEYWORDS=VALUE"; - } - return jdbcUrl; - } - - /** - * Required so that the H2 db instance is closed only when the Agroal connection pool is closed during - * Keycloak shutdown. We cannot rely on the default H2 ShutdownHook as this can result in the DB being - * closed before dependent resources, e.g. JDBC_PING2, are shutdown gracefully. This solution also - * requires the Agroal min-pool connection size to be at least 1. - */ - private static String addH2CloseOnExit(String jdbcUrl) { - if (!jdbcUrl.contains("DB_CLOSE_ON_EXIT=")) { - jdbcUrl = jdbcUrl + ";DB_CLOSE_ON_EXIT=FALSE"; - } - if (!jdbcUrl.contains("DB_CLOSE_DELAY=")) { - jdbcUrl = jdbcUrl + ";DB_CLOSE_DELAY=0"; - } - return jdbcUrl; - } - - private static String amendH2(String jdbcUrl) { - return addH2CloseOnExit(addH2NonKeywords(jdbcUrl)); - } - - private static String getDatabaseUrl(String name, String value, ConfigSourceInterceptorContext c) { - String url = Database.getDefaultUrl(name, value).orElse(null); - if (isDevModeDatabase(value)) { - String key = Optional.ofNullable(name).map( - n -> DatabaseOptions.Datasources.getNamedKey(DatabaseOptions.DB_URL_PROPERTIES, n).orElseThrow()) - .orElse(DatabaseOptions.DB_URL_PROPERTIES.getKey()); - String urlProps = Configuration.getKcConfigValue(key).getValue(); - if (urlProps != null) { - url += urlProps; - } - url = amendH2(url); - } else if (Database.getVendor(value).filter(Vendor.ORACLE::equals).isPresent()) { - var tlsMode = getDatabaseTlsMode(name); - if (tlsMode != DatabaseOptions.DatabaseTlsMode.DISABLED) { - url = Database.ORACLE_URL_PREFIX + "tcps:" + url.substring(Database.ORACLE_URL_PREFIX.length()); - } - } - return url; + private static String getDatabaseUrl(String name, String value, ConfigSourceInterceptorContext c) { + return Database.getDefaultUrl(option -> getDatasourceOptionValue(option, name).orElse(null), name, value).orElse(null); } private static String getXaOrNonXaDriver(String name, String value, ConfigSourceInterceptorContext context) { @@ -498,31 +447,33 @@ public final class DatabasePropertyMappers implements PropertyMapperGrouping { }; } - public static final class Datasources { + public static final class Datasources extends org.keycloak.config.DatabaseOptions.Datasources { /** * Automatically create mappers for datasource options */ static List> appendDatasourceMappers(List> mappers, Map, Consumer>> transformDatasourceMappers) { - List> datasourceMappers = new ArrayList<>(OPTIONS_DATASOURCES.size() + mappers.size()); + List> datasourceMappers = new ArrayList<>(mappers.size() * 2); + Map> cachedDatasourceOptions = new HashMap<>(); + cachedDatasourceOptions.put(DB.getKey(), DB_KIND); + mappers.stream().map(PropertyMapper::getOption).forEach(o -> cachedDatasourceOptions.computeIfAbsent(o.getKey(), k -> getDatasourceOption(o))); + for (var parent : mappers) { var parentOption = parent.getOption(); - var datasourceOption = getDatasourceOption(parentOption); - if (datasourceOption.isEmpty()) { - log.debugf("No datasource option found for '%s'", parentOption.getKey()); - continue; - } + var datasourceOption = cachedDatasourceOptions.get(parentOption.getKey()); - var created = fromOption(datasourceOption.get()) + var created = fromOption(datasourceOption) .isMasked(parent.isMask()) .transformer(parent.getMapper()); if (parent.getMapFrom() != null) { - var wildcardMapFromOption = getKeyForDatasource(parent.getMapFrom()) - .orElseThrow(() -> new IllegalArgumentException("Option '%s' in mapFrom() method for mapper '%s' does not have any associated wildcard option".formatted(parent.getMapFrom(), datasourceOption.get().getKey()))); - created.wildcardMapFrom(wildcardMapFromOption, parent.getParentMapper() != null ? (name, value, context) -> parent.getParentMapper().map(name, value, context) : null); + Option mapFrom = cachedDatasourceOptions.get(parent.getMapFrom()); + if (mapFrom == null) { + throw new IllegalArgumentException("Option '%s' in mapFrom() method for mapper '%s' does not have any associated wildcard option".formatted(parent.getMapFrom(), datasourceOption.getKey())); + } + created.wildcardMapFrom(mapFrom, parent.getParentMapper() != null ? (name, value, context) -> parent.getParentMapper().map(name, value, context) : null); } if (parent.getParamLabel() != null) { @@ -539,7 +490,7 @@ public final class DatabasePropertyMappers implements PropertyMapperGrouping { customTransformer.accept(created); } - Option primaryOption = getDatasourceOption(DB).orElseThrow(); + Option primaryOption = DB_KIND; PropertyMapper mapper = created.build(); // if we're not the DB option, nor mapped directly from the DB option, then @@ -653,12 +604,11 @@ public final class DatabasePropertyMappers implements PropertyMapperGrouping { return findTlsTrustStoreFile(datasource).isEmpty() ? value : null; } - private static Optional getDatasourceOptionValue(Option opt, String datasource) { - var option = datasource == null ? - Optional.of(opt.getKey()) : - getNamedKey(opt, datasource); - return option.map(Configuration::getKcConfigValue) - .map(ConfigValue::getValue); + public static Optional getDatasourceOptionValue(Option opt, String datasource) { + if (datasource == null) { + return Configuration.getOptionalKcValue(opt); + } + return opt.getWildcardKey().map(k -> WildcardOptionsUtil.getWildcardNamedKey(k, datasource)).flatMap(Configuration::getOptionalKcValue); } private static Optional findDatabaseUrl(String datasource) { @@ -671,7 +621,6 @@ public final class DatabasePropertyMappers implements PropertyMapperGrouping { private static DatabaseOptions.DatabaseTlsMode getDatabaseTlsMode(String datasource) { return getDatasourceOptionValue(DB_TLS_MODE, datasource) - .map(String::toUpperCase) .map(DatabaseOptions.DatabaseTlsMode::fromCliValue) .orElse(DatabaseOptions.DatabaseTlsMode.DISABLED); } diff --git a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/mappers/PropertyMappers.java b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/mappers/PropertyMappers.java index efc59811c46..d259fb773f7 100644 --- a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/mappers/PropertyMappers.java +++ b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/mappers/PropertyMappers.java @@ -32,7 +32,6 @@ import org.keycloak.quarkus.runtime.configuration.PersistedConfigSource; import io.smallrye.config.ConfigSourceInterceptorContext; import io.smallrye.config.ConfigValue; import io.smallrye.config.Expressions; -import org.jboss.logging.Logger; import static org.keycloak.quarkus.runtime.Environment.isRebuildCheck; import static org.keycloak.quarkus.runtime.configuration.KeycloakConfigSourceProvider.isKeyStoreConfigSource; @@ -42,7 +41,6 @@ public final class PropertyMappers { public static final String KC_SPI_PREFIX = "kc.spi"; public static String VALUE_MASK = "*******"; private static MappersConfig MAPPERS; - private static final Logger log = Logger.getLogger(PropertyMappers.class); private final static List GROUPINGS; static { GROUPINGS = List.of(new CachingPropertyMappers(), new DatabasePropertyMappers(), @@ -166,12 +164,17 @@ public final class PropertyMappers { return switch (mappers.size()) { case 0 -> null; case 1 -> mappers.get(0); - default -> { - log.tracef("Duplicated mappers for key '%s'. Used the first found.", property); - yield mappers.get(0); - } + default -> mappers.stream().filter(mapper -> !mapper.getOption().isSynthetic()).findFirst().orElse(mappers.get(0)); }; } + + /** + * Get the first non-synthetic wildcard matching the given option. + */ + public static Optional> getWildcardPropertyMapper(Option option) { + return MAPPERS.getWildcardMappers().stream() + .filter(mapper -> mapper.getOption().getKey().equals(option.getKey()) && !mapper.getOption().isSynthetic()).findFirst(); + } public static PropertyMapper getMapper(String property) { return getMapper(property, null); diff --git a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/mappers/TelemetryPropertyMappers.java b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/mappers/TelemetryPropertyMappers.java index 737e1e9f72a..c9739edfb21 100644 --- a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/mappers/TelemetryPropertyMappers.java +++ b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/mappers/TelemetryPropertyMappers.java @@ -3,6 +3,7 @@ package org.keycloak.quarkus.runtime.configuration.mappers; import java.net.MalformedURLException; import java.net.URISyntaxException; import java.net.URL; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -40,8 +41,6 @@ import static org.keycloak.config.TelemetryOptions.TELEMETRY_PROTOCOL; import static org.keycloak.config.TelemetryOptions.TELEMETRY_RESOURCE_ATTRIBUTES; import static org.keycloak.config.TelemetryOptions.TELEMETRY_SERVICE_NAME; import static org.keycloak.config.TracingOptions.TRACING_HEADER; -import static org.keycloak.config.WildcardOptionsUtil.getWildcardPrefix; -import static org.keycloak.config.WildcardOptionsUtil.getWildcardValue; import static org.keycloak.quarkus.runtime.configuration.MicroProfileConfigProvider.NS_KEYCLOAK_PREFIX; import static org.keycloak.quarkus.runtime.configuration.mappers.PropertyMapper.fromFeature; import static org.keycloak.quarkus.runtime.configuration.mappers.PropertyMapper.fromOption; @@ -250,16 +249,20 @@ public class TelemetryPropertyMappers implements PropertyMapperGrouping{ if (TELEMETRY_HEADERS_CACHE == null) { TELEMETRY_HEADERS_CACHE = new HashMap<>(); + List> wildcards = new ArrayList<>(); + Stream.of(TELEMETRY_HEADER, TELEMETRY_LOGS_HEADER, TELEMETRY_METRICS_HEADER, TRACING_HEADER) + .forEach(opt -> PropertyMappers.getWildcardPropertyMapper(opt).ifPresent(wildcards::add)); + Configuration.getPropertyNames().forEach(key -> { if (key.startsWith(NS_KEYCLOAK_PREFIX)) { - Stream.of(TELEMETRY_HEADER, TELEMETRY_LOGS_HEADER, TELEMETRY_METRICS_HEADER, TRACING_HEADER) - .filter(option -> key.startsWith(NS_KEYCLOAK_PREFIX + getWildcardPrefix(option.getKey()))) - .forEach(option -> { - String header = getWildcardValue(option, key); - String headerValue = Configuration.getOptionalValue(key) - .orElseThrow(() -> new PropertyException("Wrong value for the property '%s'".formatted(key))); - TELEMETRY_HEADERS_CACHE.computeIfAbsent(option, o -> new HashMap<>()).put(header, headerValue); - }); + wildcards.forEach(wildcard -> { + wildcard.extractWildcardValue(key).ifPresent(header -> { + String headerValue = Configuration.getOptionalValue(key).orElseThrow( + () -> new PropertyException("Wrong value for the property '%s'".formatted(key))); + TELEMETRY_HEADERS_CACHE.computeIfAbsent(wildcard.getOption(), o -> new HashMap<>()) + .put(header, headerValue); + }); + }); } }); } diff --git a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/mappers/WildcardPropertyMapper.java b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/mappers/WildcardPropertyMapper.java index f298c39e7e6..aefbb82a153 100644 --- a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/mappers/WildcardPropertyMapper.java +++ b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/mappers/WildcardPropertyMapper.java @@ -111,7 +111,7 @@ public class WildcardPropertyMapper extends PropertyMapper { public Optional extractWildcardValue(String key) { String result = null; - if (!this.option.isSynthetic() && key.startsWith(fromPrefix)) { + if (key.startsWith(fromPrefix)) { result = key.substring(fromPrefix.length()); } else if (key.startsWith(toPrefix) && key.endsWith(toSuffix)) { // TODO: this presumes that the quarkus value is quoted diff --git a/quarkus/runtime/src/test/java/org/keycloak/config/WildcardOptionsUtilTest.java b/quarkus/runtime/src/test/java/org/keycloak/config/WildcardOptionsUtilTest.java index 6c621ac2a6b..02354565fb3 100644 --- a/quarkus/runtime/src/test/java/org/keycloak/config/WildcardOptionsUtilTest.java +++ b/quarkus/runtime/src/test/java/org/keycloak/config/WildcardOptionsUtilTest.java @@ -4,7 +4,6 @@ import org.junit.Test; import static org.keycloak.config.WildcardOptionsUtil.getWildcardNamedKey; import static org.keycloak.config.WildcardOptionsUtil.getWildcardPrefix; -import static org.keycloak.config.WildcardOptionsUtil.getWildcardValue; import static org.keycloak.config.WildcardOptionsUtil.isWildcardOption; import static org.hamcrest.CoreMatchers.is; @@ -45,19 +44,4 @@ public class WildcardOptionsUtilTest { assertNull(getWildcardNamedKey("", "null")); } - @Test - public void getWildcardValueTest() { - assertThat(getWildcardValue(TracingOptions.TRACING_HEADER, "tracing-header-Authorization"), is("Authorization")); - assertThat(getWildcardValue(DatabaseOptions.DB_ENABLED_DATASOURCE, "db-enabled-my-store"), is("my-store")); - assertThat(getWildcardValue(DatabaseOptions.DB_ENABLED_DATASOURCE, "kc.db-enabled-my-store"), is("my-store")); - assertNull(getWildcardValue(TracingOptions.TRACING_HEADER, "something-wrong")); - var datasourceKindOption = DatabaseOptions.Datasources.getDatasourceOption(DatabaseOptions.DB).orElseThrow(); - assertThat(getWildcardValue(datasourceKindOption, "db-kind-user"), is("user")); - assertThat(getWildcardValue(datasourceKindOption, "db-kind-"), is("")); - assertNull(getWildcardValue(null, "db-kind-")); - assertNull(getWildcardValue(null, null)); - assertNull(getWildcardValue(TracingOptions.TRACING_HEADER, null)); - assertNull(getWildcardValue(TracingOptions.TRACING_HEADER, "")); - assertNull(getWildcardValue(TracingOptions.TRACING_HEADER, "null")); - } } diff --git a/quarkus/tests/integration/src/test/java/org/keycloak/it/cli/dist/CustomJpaEntityProviderDistTest.java b/quarkus/tests/integration/src/test/java/org/keycloak/it/cli/dist/CustomJpaEntityProviderDistTest.java index 4bb8ccb691f..49e53cd7859 100644 --- a/quarkus/tests/integration/src/test/java/org/keycloak/it/cli/dist/CustomJpaEntityProviderDistTest.java +++ b/quarkus/tests/integration/src/test/java/org/keycloak/it/cli/dist/CustomJpaEntityProviderDistTest.java @@ -57,7 +57,7 @@ public class CustomJpaEntityProviderDistTest { void notSpecifiedDbKind(CLIResult cliResult) { // it is printed at build time and the check done at runtime cliResult.assertNoMessage(MULTIPLE_DATASOURCES_MSG); - cliResult.assertError("Detected additional named datasources without a DB kind set, please specify: db-kind-new-user-store"); + cliResult.assertError("Detected additional named datasources without a DB kind set, please specify: kc.db-kind-new-user-store"); } @Test diff --git a/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testBootstrapAdminService.approved.txt b/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testBootstrapAdminService.approved.txt index d5043b87914..c7bef760764 100644 --- a/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testBootstrapAdminService.approved.txt +++ b/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testBootstrapAdminService.approved.txt @@ -119,10 +119,8 @@ Database - additional datasources: If the named datasource should be enabled at runtime. Default: true. --db-kind- - Used for named . The database vendor. In production mode the - default value of 'dev-file' is deprecated, you should explicitly specify the - db instead. Possible values are: dev-file, dev-mem, mariadb, mssql, mysql, - oracle, postgres, tidb. + Used for named . The database vendor. Possible values are: + dev-file, dev-mem, mariadb, mssql, mysql, oracle, postgres, tidb. --db-log-slow-queries-threshold- Used for named . Log SQL statements slower than the configured threshold with logger org.hibernate.SQL_SLOW and log-level info. Default: diff --git a/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testBootstrapAdminUser.approved.txt b/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testBootstrapAdminUser.approved.txt index ffc23e72fe4..fb6df6c1a39 100644 --- a/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testBootstrapAdminUser.approved.txt +++ b/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testBootstrapAdminUser.approved.txt @@ -121,10 +121,8 @@ Database - additional datasources: If the named datasource should be enabled at runtime. Default: true. --db-kind- - Used for named . The database vendor. In production mode the - default value of 'dev-file' is deprecated, you should explicitly specify the - db instead. Possible values are: dev-file, dev-mem, mariadb, mssql, mysql, - oracle, postgres, tidb. + Used for named . The database vendor. Possible values are: + dev-file, dev-mem, mariadb, mssql, mysql, oracle, postgres, tidb. --db-log-slow-queries-threshold- Used for named . Log SQL statements slower than the configured threshold with logger org.hibernate.SQL_SLOW and log-level info. Default: diff --git a/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testBuildHelp.approved.txt b/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testBuildHelp.approved.txt index 5b30b7296bd..5bd1ee977b1 100644 --- a/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testBuildHelp.approved.txt +++ b/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testBuildHelp.approved.txt @@ -33,10 +33,8 @@ Database - additional datasources: driver. If not set, a default driver is set accordingly to the chosen database. --db-kind- - Used for named . The database vendor. In production mode the - default value of 'dev-file' is deprecated, you should explicitly specify the - db instead. Possible values are: dev-file, dev-mem, mariadb, mssql, mysql, - oracle, postgres, tidb. + Used for named . The database vendor. Possible values are: + dev-file, dev-mem, mariadb, mssql, mysql, oracle, postgres, tidb. Transaction: @@ -144,4 +142,4 @@ Examples: Change the relative path: - $ kc.sh build --http-relative-path=/auth + $ kc.sh build --http-relative-path=/auth \ No newline at end of file diff --git a/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testExportHelp.approved.txt b/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testExportHelp.approved.txt index 0c0315727bf..487ebd268f8 100644 --- a/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testExportHelp.approved.txt +++ b/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testExportHelp.approved.txt @@ -114,10 +114,8 @@ Database - additional datasources: If the named datasource should be enabled at runtime. Default: true. --db-kind- - Used for named . The database vendor. In production mode the - default value of 'dev-file' is deprecated, you should explicitly specify the - db instead. Possible values are: dev-file, dev-mem, mariadb, mssql, mysql, - oracle, postgres, tidb. + Used for named . The database vendor. Possible values are: + dev-file, dev-mem, mariadb, mssql, mysql, oracle, postgres, tidb. --db-log-slow-queries-threshold- Used for named . Log SQL statements slower than the configured threshold with logger org.hibernate.SQL_SLOW and log-level info. Default: diff --git a/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testExportHelpAll.approved.txt b/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testExportHelpAll.approved.txt index 1a7e93a2e25..153e7d1f131 100644 --- a/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testExportHelpAll.approved.txt +++ b/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testExportHelpAll.approved.txt @@ -114,10 +114,8 @@ Database - additional datasources: If the named datasource should be enabled at runtime. Default: true. --db-kind- - Used for named . The database vendor. In production mode the - default value of 'dev-file' is deprecated, you should explicitly specify the - db instead. Possible values are: dev-file, dev-mem, mariadb, mssql, mysql, - oracle, postgres, tidb. + Used for named . The database vendor. Possible values are: + dev-file, dev-mem, mariadb, mssql, mysql, oracle, postgres, tidb. --db-log-slow-queries-threshold- Used for named . Log SQL statements slower than the configured threshold with logger org.hibernate.SQL_SLOW and log-level info. Default: diff --git a/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testImportHelp.approved.txt b/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testImportHelp.approved.txt index 486e70dddb2..a8dbca41a3d 100644 --- a/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testImportHelp.approved.txt +++ b/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testImportHelp.approved.txt @@ -114,10 +114,8 @@ Database - additional datasources: If the named datasource should be enabled at runtime. Default: true. --db-kind- - Used for named . The database vendor. In production mode the - default value of 'dev-file' is deprecated, you should explicitly specify the - db instead. Possible values are: dev-file, dev-mem, mariadb, mssql, mysql, - oracle, postgres, tidb. + Used for named . The database vendor. Possible values are: + dev-file, dev-mem, mariadb, mssql, mysql, oracle, postgres, tidb. --db-log-slow-queries-threshold- Used for named . Log SQL statements slower than the configured threshold with logger org.hibernate.SQL_SLOW and log-level info. Default: diff --git a/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testImportHelpAll.approved.txt b/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testImportHelpAll.approved.txt index 44b14403ae6..e0e7b1c0427 100644 --- a/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testImportHelpAll.approved.txt +++ b/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testImportHelpAll.approved.txt @@ -114,10 +114,8 @@ Database - additional datasources: If the named datasource should be enabled at runtime. Default: true. --db-kind- - Used for named . The database vendor. In production mode the - default value of 'dev-file' is deprecated, you should explicitly specify the - db instead. Possible values are: dev-file, dev-mem, mariadb, mssql, mysql, - oracle, postgres, tidb. + Used for named . The database vendor. Possible values are: + dev-file, dev-mem, mariadb, mssql, mysql, oracle, postgres, tidb. --db-log-slow-queries-threshold- Used for named . Log SQL statements slower than the configured threshold with logger org.hibernate.SQL_SLOW and log-level info. Default: diff --git a/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testStartDevHelp.approved.txt b/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testStartDevHelp.approved.txt index 3b1d81e37c9..aa326f07641 100644 --- a/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testStartDevHelp.approved.txt +++ b/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testStartDevHelp.approved.txt @@ -162,10 +162,8 @@ Database - additional datasources: If the named datasource should be enabled at runtime. Default: true. --db-kind- - Used for named . The database vendor. In production mode the - default value of 'dev-file' is deprecated, you should explicitly specify the - db instead. Possible values are: dev-file, dev-mem, mariadb, mssql, mysql, - oracle, postgres, tidb. + Used for named . The database vendor. Possible values are: + dev-file, dev-mem, mariadb, mssql, mysql, oracle, postgres, tidb. --db-log-slow-queries-threshold- Used for named . Log SQL statements slower than the configured threshold with logger org.hibernate.SQL_SLOW and log-level info. Default: diff --git a/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testStartDevHelpAll.approved.txt b/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testStartDevHelpAll.approved.txt index 745e818006d..8623c52fbeb 100644 --- a/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testStartDevHelpAll.approved.txt +++ b/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testStartDevHelpAll.approved.txt @@ -232,10 +232,8 @@ Database - additional datasources: If the named datasource should be enabled at runtime. Default: true. --db-kind- - Used for named . The database vendor. In production mode the - default value of 'dev-file' is deprecated, you should explicitly specify the - db instead. Possible values are: dev-file, dev-mem, mariadb, mssql, mysql, - oracle, postgres, tidb. + Used for named . The database vendor. Possible values are: + dev-file, dev-mem, mariadb, mssql, mysql, oracle, postgres, tidb. --db-log-slow-queries-threshold- Used for named . Log SQL statements slower than the configured threshold with logger org.hibernate.SQL_SLOW and log-level info. Default: diff --git a/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testStartHelp.approved.txt b/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testStartHelp.approved.txt index 6bcf6f28e72..c94a5a597f4 100644 --- a/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testStartHelp.approved.txt +++ b/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testStartHelp.approved.txt @@ -210,10 +210,8 @@ Database - additional datasources: If the named datasource should be enabled at runtime. Default: true. --db-kind- - Used for named . The database vendor. In production mode the - default value of 'dev-file' is deprecated, you should explicitly specify the - db instead. Possible values are: dev-file, dev-mem, mariadb, mssql, mysql, - oracle, postgres, tidb. + Used for named . The database vendor. Possible values are: + dev-file, dev-mem, mariadb, mssql, mysql, oracle, postgres, tidb. --db-log-slow-queries-threshold- Used for named . Log SQL statements slower than the configured threshold with logger org.hibernate.SQL_SLOW and log-level info. Default: diff --git a/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testStartHelpAll.approved.txt b/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testStartHelpAll.approved.txt index 587f7ba01ea..a02cc2da375 100644 --- a/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testStartHelpAll.approved.txt +++ b/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testStartHelpAll.approved.txt @@ -233,10 +233,8 @@ Database - additional datasources: If the named datasource should be enabled at runtime. Default: true. --db-kind- - Used for named . The database vendor. In production mode the - default value of 'dev-file' is deprecated, you should explicitly specify the - db instead. Possible values are: dev-file, dev-mem, mariadb, mssql, mysql, - oracle, postgres, tidb. + Used for named . The database vendor. Possible values are: + dev-file, dev-mem, mariadb, mssql, mysql, oracle, postgres, tidb. --db-log-slow-queries-threshold- Used for named . Log SQL statements slower than the configured threshold with logger org.hibernate.SQL_SLOW and log-level info. Default: diff --git a/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testUpdateCompatibilityCheckHelp.approved.txt b/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testUpdateCompatibilityCheckHelp.approved.txt index f1e99e87ac7..2d75acec7e8 100644 --- a/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testUpdateCompatibilityCheckHelp.approved.txt +++ b/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testUpdateCompatibilityCheckHelp.approved.txt @@ -209,10 +209,8 @@ Database - additional datasources: If the named datasource should be enabled at runtime. Default: true. --db-kind- - Used for named . The database vendor. In production mode the - default value of 'dev-file' is deprecated, you should explicitly specify the - db instead. Possible values are: dev-file, dev-mem, mariadb, mssql, mysql, - oracle, postgres, tidb. + Used for named . The database vendor. Possible values are: + dev-file, dev-mem, mariadb, mssql, mysql, oracle, postgres, tidb. --db-log-slow-queries-threshold- Used for named . Log SQL statements slower than the configured threshold with logger org.hibernate.SQL_SLOW and log-level info. Default: diff --git a/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testUpdateCompatibilityCheckHelpAll.approved.txt b/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testUpdateCompatibilityCheckHelpAll.approved.txt index 272a5198a3a..464e6bedb73 100644 --- a/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testUpdateCompatibilityCheckHelpAll.approved.txt +++ b/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testUpdateCompatibilityCheckHelpAll.approved.txt @@ -232,10 +232,8 @@ Database - additional datasources: If the named datasource should be enabled at runtime. Default: true. --db-kind- - Used for named . The database vendor. In production mode the - default value of 'dev-file' is deprecated, you should explicitly specify the - db instead. Possible values are: dev-file, dev-mem, mariadb, mssql, mysql, - oracle, postgres, tidb. + Used for named . The database vendor. Possible values are: + dev-file, dev-mem, mariadb, mssql, mysql, oracle, postgres, tidb. --db-log-slow-queries-threshold- Used for named . Log SQL statements slower than the configured threshold with logger org.hibernate.SQL_SLOW and log-level info. Default: diff --git a/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testUpdateCompatibilityMetadataHelp.approved.txt b/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testUpdateCompatibilityMetadataHelp.approved.txt index adb4d5249ad..e04fa1022c9 100644 --- a/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testUpdateCompatibilityMetadataHelp.approved.txt +++ b/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testUpdateCompatibilityMetadataHelp.approved.txt @@ -207,10 +207,8 @@ Database - additional datasources: If the named datasource should be enabled at runtime. Default: true. --db-kind- - Used for named . The database vendor. In production mode the - default value of 'dev-file' is deprecated, you should explicitly specify the - db instead. Possible values are: dev-file, dev-mem, mariadb, mssql, mysql, - oracle, postgres, tidb. + Used for named . The database vendor. Possible values are: + dev-file, dev-mem, mariadb, mssql, mysql, oracle, postgres, tidb. --db-log-slow-queries-threshold- Used for named . Log SQL statements slower than the configured threshold with logger org.hibernate.SQL_SLOW and log-level info. Default: diff --git a/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testUpdateCompatibilityMetadataHelpAll.approved.txt b/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testUpdateCompatibilityMetadataHelpAll.approved.txt index a42e1e8d756..f0c15ed176f 100644 --- a/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testUpdateCompatibilityMetadataHelpAll.approved.txt +++ b/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testUpdateCompatibilityMetadataHelpAll.approved.txt @@ -230,10 +230,8 @@ Database - additional datasources: If the named datasource should be enabled at runtime. Default: true. --db-kind- - Used for named . The database vendor. In production mode the - default value of 'dev-file' is deprecated, you should explicitly specify the - db instead. Possible values are: dev-file, dev-mem, mariadb, mssql, mysql, - oracle, postgres, tidb. + Used for named . The database vendor. Possible values are: + dev-file, dev-mem, mariadb, mssql, mysql, oracle, postgres, tidb. --db-log-slow-queries-threshold- Used for named . Log SQL statements slower than the configured threshold with logger org.hibernate.SQL_SLOW and log-level info. Default: From 2ffb8b676edc7a340423be32f9e26696303649fd Mon Sep 17 00:00:00 2001 From: Steven Hawkins Date: Fri, 22 May 2026 13:40:31 -0400 Subject: [PATCH 2/5] fix: prevent service account name from being set in multi-namespace mode (#49036) closes: #48382 Signed-off-by: Steve Hawkins --- .../controllers/KeycloakController.java | 24 +++++-- .../KeycloakDeploymentDependentResource.java | 5 ++ .../unit/KeycloakControllerTest.java | 23 +++++++ .../testsuite/unit/PodTemplateTest.java | 65 ++++++++++++++++--- 4 files changed, 102 insertions(+), 15 deletions(-) diff --git a/operator/src/main/java/org/keycloak/operator/controllers/KeycloakController.java b/operator/src/main/java/org/keycloak/operator/controllers/KeycloakController.java index 44935277597..8e077187414 100644 --- a/operator/src/main/java/org/keycloak/operator/controllers/KeycloakController.java +++ b/operator/src/main/java/org/keycloak/operator/controllers/KeycloakController.java @@ -215,7 +215,7 @@ public class KeycloakController implements Reconciler { public void updateStatus(Keycloak keycloakCR, StatefulSet existingDeployment, KeycloakStatusAggregator status, Context context) { status.apply(b -> b.withSelector(Utils.toSelectorString(Utils.allInstanceLabels(keycloakCR)))); - validatePodTemplate(keycloakCR, status); + validatePodTemplate(keycloakCR, status, context); if (existingDeployment == null) { status.addNotReadyMessage("No existing StatefulSet found, waiting for creating a new one"); return; @@ -251,6 +251,11 @@ public class KeycloakController implements Reconciler { .ifPresent(status::addWarningMessage); } + static boolean isMultiNamespace(Context context) { + var config = context.getControllerConfiguration().getInformerConfig(); + return config.watchAllNamespaces() || config.getNamespaces().size() > 1; + } + public static boolean isRolling(StatefulSet existingDeployment) { return existingDeployment.getStatus() != null && existingDeployment.getStatus().getCurrentRevision() != null @@ -258,7 +263,7 @@ public class KeycloakController implements Reconciler { && !existingDeployment.getStatus().getCurrentRevision().equals(existingDeployment.getStatus().getUpdateRevision()); } - public void validatePodTemplate(Keycloak keycloakCR, KeycloakStatusAggregator status) { + public void validatePodTemplate(Keycloak keycloakCR, KeycloakStatusAggregator status, Context context) { var spec = KeycloakDeploymentDependentResource.getPodTemplateSpec(keycloakCR); if (spec.isEmpty()) { return; @@ -274,7 +279,8 @@ public class KeycloakController implements Reconciler { } } - Optional.ofNullable(overlayTemplate.getSpec()).map(PodSpec::getContainers).flatMap(l -> l.stream().findFirst()) + Optional templateSpec = Optional.ofNullable(overlayTemplate.getSpec()); + templateSpec.map(PodSpec::getContainers).flatMap(l -> l.stream().findFirst()) .ifPresent(container -> { if (container.getName() != null) { status.addWarningMessage("The name of the keycloak container cannot be modified"); @@ -288,10 +294,14 @@ public class KeycloakController implements Reconciler { } }); - if (overlayTemplate.getSpec() != null && - CollectionUtil.isNotEmpty(overlayTemplate.getSpec().getImagePullSecrets())) { - status.addWarningMessage("The imagePullSecrets of the keycloak container cannot be modified using podTemplate"); - } + templateSpec.ifPresent(ts -> { + if (CollectionUtil.isNotEmpty(ts.getImagePullSecrets())) { + status.addWarningMessage("The imagePullSecrets of the keycloak container cannot be modified using podTemplate"); + } + if (isMultiNamespace(context) && Optional.ofNullable(ts.getServiceAccount()).orElse(ts.getServiceAccountName()) != null) { + status.addWarningMessage("The serviceAccountName cannot be set in a multi-namespace install mode"); + } + }); } private void checkForPodErrors(KeycloakStatusAggregator status, Keycloak keycloak, StatefulSet existingDeployment, Context context) { diff --git a/operator/src/main/java/org/keycloak/operator/controllers/KeycloakDeploymentDependentResource.java b/operator/src/main/java/org/keycloak/operator/controllers/KeycloakDeploymentDependentResource.java index 4c45daf150c..38560cd7ebf 100644 --- a/operator/src/main/java/org/keycloak/operator/controllers/KeycloakDeploymentDependentResource.java +++ b/operator/src/main/java/org/keycloak/operator/controllers/KeycloakDeploymentDependentResource.java @@ -313,6 +313,11 @@ public class KeycloakDeploymentDependentResource extends VersionTolerantCRUDKube .endTemplate() .withReplicas(keycloakCR.getSpec().getInstances()) .endSpec(); + + if (KeycloakController.isMultiNamespace(context)) { + baseDeploymentBuilder = baseDeploymentBuilder.editSpec().editTemplate().editSpec().withServiceAccount(null) + .withServiceAccountName(null).endSpec().endTemplate().endSpec(); + } var specBuilder = baseDeploymentBuilder.editSpec().editTemplate().editOrNewSpec(); diff --git a/operator/src/test/java/org/keycloak/operator/testsuite/unit/KeycloakControllerTest.java b/operator/src/test/java/org/keycloak/operator/testsuite/unit/KeycloakControllerTest.java index e38b894f98f..09086449b0d 100644 --- a/operator/src/test/java/org/keycloak/operator/testsuite/unit/KeycloakControllerTest.java +++ b/operator/src/test/java/org/keycloak/operator/testsuite/unit/KeycloakControllerTest.java @@ -19,7 +19,10 @@ package org.keycloak.operator.testsuite.unit; import org.keycloak.operator.controllers.KeycloakController; import org.keycloak.operator.crds.v2beta1.deployment.Keycloak; +import org.keycloak.operator.crds.v2beta1.deployment.KeycloakBuilder; +import org.keycloak.operator.crds.v2beta1.deployment.KeycloakStatusAggregator; import org.keycloak.operator.crds.v2beta1.deployment.spec.IngressSpecBuilder; +import org.keycloak.operator.testsuite.utils.CRAssert; import org.keycloak.operator.testsuite.utils.K8sUtils; import io.fabric8.kubernetes.client.dsl.Resource; @@ -67,5 +70,25 @@ class KeycloakControllerTest { assertEquals(1, update.getResource().orElseThrow().getSpec().getInstances()); assertNull(update.getResource().orElseThrow().getSpec().getHostnameSpec().getHostname()); } + + @Test + void testUpdateStatus() { + KeycloakController controller = new KeycloakController(); + Keycloak kc = K8sUtils.getDefaultKeycloakDeployment(); + kc = new KeycloakBuilder(kc).editSpec().withNewUnsupported().withNewPodTemplate().withNewSpec() + .withServiceAccountName("foo").endSpec().endPodTemplate().endUnsupported().endSpec().build(); + + Context mockContext = Mockito.mock(Context.class, Mockito.RETURNS_DEEP_STUBS); + + KeycloakStatusAggregator agg = new KeycloakStatusAggregator(null, 1L); + controller.updateStatus(kc, null, agg, mockContext); + CRAssert.assertKeycloakStatusCondition(agg.build(), "HasErrors", false, null, 1L).extracting("message").isEqualTo(""); + + Mockito.when(mockContext.getControllerConfiguration().getInformerConfig().watchAllNamespaces()).thenReturn(true); + + agg = new KeycloakStatusAggregator(null, 1L); + controller.updateStatus(kc, null, agg, mockContext); + CRAssert.assertKeycloakStatusCondition(agg.build(), "HasErrors", false, "The serviceAccountName cannot be set in a multi-namespace install mode"); + } } diff --git a/operator/src/test/java/org/keycloak/operator/testsuite/unit/PodTemplateTest.java b/operator/src/test/java/org/keycloak/operator/testsuite/unit/PodTemplateTest.java index dba58a03c76..3696357bfad 100644 --- a/operator/src/test/java/org/keycloak/operator/testsuite/unit/PodTemplateTest.java +++ b/operator/src/test/java/org/keycloak/operator/testsuite/unit/PodTemplateTest.java @@ -20,6 +20,7 @@ package org.keycloak.operator.testsuite.unit; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.function.Consumer; import java.util.function.Function; import java.util.stream.Collectors; @@ -71,6 +72,7 @@ import io.fabric8.kubernetes.api.model.apps.StatefulSetSpec; import io.fabric8.kubernetes.api.model.batch.v1.Job; import io.fabric8.kubernetes.client.KubernetesClient; import io.fabric8.kubernetes.client.utils.Serialization; +import io.javaoperatorsdk.operator.api.config.informer.InformerConfiguration; import io.javaoperatorsdk.operator.api.reconciler.Context; import io.javaoperatorsdk.operator.api.reconciler.dependent.managed.ManagedWorkflowAndDependentResourceContext; import io.quarkus.test.InjectMock; @@ -111,10 +113,15 @@ public class PodTemplateTest { @Inject KeycloakRealmImportJobDependentResource importJobResource; + + Context context; @BeforeEach protected void setup() { this.deployment = new KeycloakDeploymentDependentResource(); + context = Mockito.mock(Context.class, Mockito.RETURNS_DEEP_STUBS); + Mockito.when(context.getClient()).thenReturn(Mockito.mock(KubernetesClient.class)); + Mockito.when(context.getControllerConfiguration().getInformerConfig()).thenReturn(Mockito.mock(InformerConfiguration.class)); } private StatefulSet getDeployment(PodTemplateSpec podTemplate, StatefulSet existingDeployment, Consumer additionalSpec) { @@ -125,7 +132,7 @@ public class PodTemplateTest { .endSelector().endSpec().build(); //noinspection unchecked - Context context = mockContext(null); + mockContext(null); return deployment.initialDesired(kc, context); } @@ -147,16 +154,13 @@ public class PodTemplateTest { return kc; } - private Context mockContext(StatefulSet existingDeployment) { - Context context = Mockito.mock(Context.class); + private void mockContext(StatefulSet existingDeployment) { ManagedWorkflowAndDependentResourceContext managedWorkflowAndDependentResourceContext = Mockito.mock(ManagedWorkflowAndDependentResourceContext.class); Mockito.when(context.managedWorkflowAndDependentResourceContext()).thenReturn(managedWorkflowAndDependentResourceContext); Mockito.when(managedWorkflowAndDependentResourceContext.get(OLD_DEPLOYMENT_KEY, StatefulSet.class)).thenReturn(Optional.ofNullable(existingDeployment)); Mockito.when(managedWorkflowAndDependentResourceContext.getMandatory(OPERATOR_CONFIG_KEY, Config.class)).thenReturn(operatorConfig); Mockito.when(managedWorkflowAndDependentResourceContext.getMandatory(WATCHED_RESOURCES_KEY, WatchedResources.class)).thenReturn(watchedResources); Mockito.when(managedWorkflowAndDependentResourceContext.getMandatory(DIST_CONFIGURATOR_KEY, KeycloakDistConfigurator.class)).thenReturn(distConfigurator); - Mockito.when(context.getClient()).thenReturn(Mockito.mock(KubernetesClient.class)); - return context; } private StatefulSet getDeployment(PodTemplateSpec podTemplate, StatefulSet existingDeployment) { @@ -404,6 +408,51 @@ public class PodTemplateTest { // Assert assertThat(podTemplate.getMetadata().getAnnotations()).containsEntry("two", "2"); } + + @Test + public void testServiceAccountName() { + // in a single namespace, we'll still allow setting via the template + + // Arrange + var additionalPodTemplate = new PodTemplateSpecBuilder() + .withNewSpec() + .withServiceAccount("foo") + .endSpec() + .build(); + + // Act + var podTemplate = getDeployment(additionalPodTemplate).getSpec().getTemplate(); + + // Assert + assertThat(podTemplate.getSpec().getServiceAccount()).isEqualTo("foo"); + + // in multinamespace we won't + + Mockito.when(context.getControllerConfiguration().getInformerConfig().watchAllNamespaces()).thenReturn(true); + + // Act + podTemplate = getDeployment(additionalPodTemplate).getSpec().getTemplate(); + + // Assert + assertThat(podTemplate.getSpec().getServiceAccount()).isNull(); + + // test again with serviceAccountName + + additionalPodTemplate = new PodTemplateSpecBuilder() + .withNewSpec() + .withServiceAccountName("bar") + .endSpec() + .build(); + + Mockito.when(context.getControllerConfiguration().getInformerConfig().watchAllNamespaces()).thenReturn(false); + Mockito.when(context.getControllerConfiguration().getInformerConfig().getNamespaces()).thenReturn(Set.of("one", "two")); + + // Act + podTemplate = getDeployment(additionalPodTemplate).getSpec().getTemplate(); + + // Assert + assertThat(podTemplate.getSpec().getServiceAccountName()).isNull(); + } @Test public void testHttpManagment() { @@ -789,7 +838,7 @@ public class PodTemplateTest { StatefulSetBuilder desired = getDeployment(null, existingStatefulSet, newSpec).toBuilder(); // setup the mock context - Context context = mockContext(null); + mockContext(null); var managedWorkflowAndDependentResourceContext = context.managedWorkflowAndDependentResourceContext(); Mockito.when(managedWorkflowAndDependentResourceContext.get(OLD_DEPLOYMENT_KEY, StatefulSet.class)).thenReturn(Optional.of(existingStatefulSet)); Mockito.when(managedWorkflowAndDependentResourceContext.getMandatory(NEW_DEPLOYMENT_KEY, StatefulSet.class)).thenReturn(desired.build()); @@ -802,7 +851,7 @@ public class PodTemplateTest { existingModifier.accept(existingBuilder); StatefulSet existingStatefulSet = existingBuilder.build(); - Context context = mockContext(existingStatefulSet); + mockContext(existingStatefulSet); var kc = createKeycloak(null, keycloakSpec); Mockito.when(context.managedWorkflowAndDependentResourceContext().getMandatory(ContextUtils.KEYCLOAK, Keycloak.class)).thenReturn(kc); @@ -893,7 +942,7 @@ public class PodTemplateTest { assertNull(job.getSpec().getTemplate().getSpec().getInitContainers().get(0).getLifecycle()); assertNull(job.getSpec().getTemplate().getSpec().getInitContainers().get(0).getRestartPolicy()); } - + @Test public void testEnvVars() { var statefulSet = getDeployment(null, null, builder -> builder.addNewEnv("JAVA_OPTS", "my opts") From 26ef6d1b085466c4a7d3b51a45692e9f30a8fef5 Mon Sep 17 00:00:00 2001 From: Steven Hawkins Date: Fri, 22 May 2026 13:54:20 -0400 Subject: [PATCH 3/5] task: using a beanparam for client listing options (#49074) * task: using a beanparam for client listing options closes: #48650 Signed-off-by: Steve Hawkins * just adding fluent methods Signed-off-by: Steve Hawkins --------- Signed-off-by: Steve Hawkins --- js/libs/keycloak-admin-client/openapi.yaml | 3 +- .../org/keycloak/admin/api/ListOptions.java | 28 +++++++++++++++++++ .../keycloak/admin/api/client/ClientsApi.java | 9 +++--- .../admin/api/client/DefaultClientsApi.java | 6 ++-- .../admin/client/v2/ClientApiV2Test.java | 5 ++-- 5 files changed, 39 insertions(+), 12 deletions(-) create mode 100644 rest/admin-v2/api/src/main/java/org/keycloak/admin/api/ListOptions.java diff --git a/js/libs/keycloak-admin-client/openapi.yaml b/js/libs/keycloak-admin-client/openapi.yaml index 1b599075664..39ea5bf8eb4 100644 --- a/js/libs/keycloak-admin-client/openapi.yaml +++ b/js/libs/keycloak-admin-client/openapi.yaml @@ -181,8 +181,7 @@ paths: - Clients (v2) parameters: - description: "Set of fields to include in the response. Must be top-level\ - \ fields on one of the client types. If omitted or empty, all fields will\ - \ be populated." + \ fields. If omitted or empty, all fields will be populated." name: fields in: query schema: diff --git a/rest/admin-v2/api/src/main/java/org/keycloak/admin/api/ListOptions.java b/rest/admin-v2/api/src/main/java/org/keycloak/admin/api/ListOptions.java new file mode 100644 index 00000000000..e3a83104524 --- /dev/null +++ b/rest/admin-v2/api/src/main/java/org/keycloak/admin/api/ListOptions.java @@ -0,0 +1,28 @@ +package org.keycloak.admin.api; + +import java.util.Set; + +import jakarta.ws.rs.QueryParam; + +import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; + +public class ListOptions { + + @Parameter(description = "Set of fields to include in the response. Must be top-level fields. If omitted or empty, all fields will be populated.") + @QueryParam("fields") + protected Set fields; + + public ListOptions fields(Set fields) { + this.setFields(fields); + return this; + } + + public Set getFields() { + return fields; + } + + public void setFields(Set fields) { + this.fields = fields; + } + +} diff --git a/rest/admin-v2/api/src/main/java/org/keycloak/admin/api/client/ClientsApi.java b/rest/admin-v2/api/src/main/java/org/keycloak/admin/api/client/ClientsApi.java index 93f882af3ee..43d91a22f99 100644 --- a/rest/admin-v2/api/src/main/java/org/keycloak/admin/api/client/ClientsApi.java +++ b/rest/admin-v2/api/src/main/java/org/keycloak/admin/api/client/ClientsApi.java @@ -1,19 +1,19 @@ package org.keycloak.admin.api.client; -import java.util.Set; import java.util.stream.Stream; import jakarta.validation.Valid; +import jakarta.ws.rs.BeanParam; import jakarta.ws.rs.Consumes; import jakarta.ws.rs.GET; import jakarta.ws.rs.POST; import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; import jakarta.ws.rs.Produces; -import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; +import org.keycloak.admin.api.ListOptions; import org.keycloak.common.constants.KeycloakOpenAPI; import org.keycloak.representations.admin.v2.BaseClientRepresentation; @@ -22,7 +22,6 @@ import org.eclipse.microprofile.openapi.annotations.enums.SchemaType; import org.eclipse.microprofile.openapi.annotations.extensions.Extension; import org.eclipse.microprofile.openapi.annotations.media.Content; import org.eclipse.microprofile.openapi.annotations.media.Schema; -import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; import org.eclipse.microprofile.openapi.annotations.tags.Tag; @@ -38,10 +37,10 @@ public interface ClientsApi { @APIResponses(value = { @APIResponse(responseCode = "200", content = @Content(schema = @Schema(type = SchemaType.ARRAY, implementation = BaseClientRepresentation.class))) }) - Stream getClients(@Parameter(description = "Set of fields to include in the response. Must be top-level fields on one of the client types. If omitted or empty, all fields will be populated.") @QueryParam("fields") Set fields); + Stream getClients(@BeanParam ListOptions params); default Stream getClients() { - return getClients(Set.of()); + return getClients(new ListOptions()); } @POST diff --git a/rest/admin-v2/services/src/main/java/org/keycloak/rest/admin/api/client/DefaultClientsApi.java b/rest/admin-v2/services/src/main/java/org/keycloak/rest/admin/api/client/DefaultClientsApi.java index af993176941..a03ff64f04f 100644 --- a/rest/admin-v2/services/src/main/java/org/keycloak/rest/admin/api/client/DefaultClientsApi.java +++ b/rest/admin-v2/services/src/main/java/org/keycloak/rest/admin/api/client/DefaultClientsApi.java @@ -1,6 +1,5 @@ package org.keycloak.rest.admin.api.client; -import java.util.Set; import java.util.stream.Stream; import jakarta.annotation.Nonnull; @@ -11,6 +10,7 @@ import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; import jakarta.ws.rs.core.Response; +import org.keycloak.admin.api.ListOptions; import org.keycloak.admin.api.client.ClientApi; import org.keycloak.admin.api.client.ClientsApi; import org.keycloak.models.KeycloakSession; @@ -38,8 +38,8 @@ public class DefaultClientsApi implements ClientsApi { } @Override - public Stream getClients(Set fields) { - return clientService.getClients(realm, new ClientProjectionOptions(fields), null, null); + public Stream getClients(ListOptions params) { + return clientService.getClients(realm, new ClientProjectionOptions(params.getFields()), null, null); } @POST diff --git a/rest/admin-v2/tests/src/test/java/org/keycloak/tests/admin/client/v2/ClientApiV2Test.java b/rest/admin-v2/tests/src/test/java/org/keycloak/tests/admin/client/v2/ClientApiV2Test.java index 7a7a7e6f7ed..fc0682c87bd 100644 --- a/rest/admin-v2/tests/src/test/java/org/keycloak/tests/admin/client/v2/ClientApiV2Test.java +++ b/rest/admin-v2/tests/src/test/java/org/keycloak/tests/admin/client/v2/ClientApiV2Test.java @@ -30,6 +30,7 @@ import jakarta.ws.rs.NotFoundException; import jakarta.ws.rs.core.HttpHeaders; import jakarta.ws.rs.core.MediaType; +import org.keycloak.admin.api.ListOptions; import org.keycloak.admin.api.PatchTypeNames; import org.keycloak.admin.client.Keycloak; import org.keycloak.authentication.authenticators.client.ClientIdAndSecretAuthenticator; @@ -347,7 +348,7 @@ public class ClientApiV2Test extends AbstractClientApiV2Test{ assertThat(samlClient.getFrontChannelLogout(), is(false)); // test projecting only id and protocol - try (Stream baseClientRepresentationStream = getClientsApi().getClients(Set.of("clientId", "protocol"))) { + try (Stream baseClientRepresentationStream = getClientsApi().getClients(new ListOptions().fields(Set.of("clientId", "protocol")))) { List clients = baseClientRepresentationStream.toList(); for (BaseClientRepresentation client : clients) { BaseClientRepresentation toCompare = null; @@ -364,7 +365,7 @@ public class ClientApiV2Test extends AbstractClientApiV2Test{ @Test public void invalidFieldProjection() { - BadRequestException e = assertThrows(BadRequestException.class, () -> getClientsApi().getClients(Set.of("unknown!"))); + BadRequestException e = assertThrows(BadRequestException.class, () -> getClientsApi().getClients(new ListOptions().fields(Set.of("unknown!")))); assertEquals("{\"error\":\"unknown! is an unknown field\"}", e.getResponse().readEntity(String.class)); } From 94dcc24a8d3d7f6d3b73ce0c12957555276efbd1 Mon Sep 17 00:00:00 2001 From: Ricardo Martin Date: Sat, 23 May 2026 19:54:51 +0200 Subject: [PATCH 4/5] Upgrade playwright to avoid hangs on CI Closes #49274 Signed-off-by: rmartinc --- js/apps/account-ui/package.json | 2 +- js/apps/admin-ui/package.json | 2 +- js/pnpm-lock.yaml | 44 ++++++++++++++++----------------- 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/js/apps/account-ui/package.json b/js/apps/account-ui/package.json index f44ac2b206c..a32e8eff24a 100644 --- a/js/apps/account-ui/package.json +++ b/js/apps/account-ui/package.json @@ -42,7 +42,7 @@ }, "devDependencies": { "@keycloak/keycloak-admin-client": "workspace:*", - "@playwright/test": "^1.57.0", + "@playwright/test": "^1.60.0", "@types/lodash-es": "^4.17.12", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", diff --git a/js/apps/admin-ui/package.json b/js/apps/admin-ui/package.json index 7bb86b8d562..c1e80a16809 100644 --- a/js/apps/admin-ui/package.json +++ b/js/apps/admin-ui/package.json @@ -113,7 +113,7 @@ }, "devDependencies": { "@axe-core/playwright": "^4.11.0", - "@playwright/test": "^1.57.0", + "@playwright/test": "^1.60.0", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.1", diff --git a/js/pnpm-lock.yaml b/js/pnpm-lock.yaml index 59e9ec97a17..271868d36cf 100644 --- a/js/pnpm-lock.yaml +++ b/js/pnpm-lock.yaml @@ -125,8 +125,8 @@ importers: specifier: workspace:* version: link:../../libs/keycloak-admin-client '@playwright/test': - specifier: ^1.57.0 - version: 1.57.0 + specifier: ^1.60.0 + version: 1.60.0 '@types/lodash-es': specifier: ^4.17.12 version: 4.17.12 @@ -232,10 +232,10 @@ importers: devDependencies: '@axe-core/playwright': specifier: ^4.11.0 - version: 4.11.0(playwright-core@1.57.0) + version: 4.11.0(playwright-core@1.60.0) '@playwright/test': - specifier: ^1.57.0 - version: 1.57.0 + specifier: ^1.60.0 + version: 1.60.0 '@testing-library/dom': specifier: ^10.4.1 version: 10.4.1 @@ -1206,8 +1206,8 @@ packages: resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} - '@playwright/test@1.57.0': - resolution: {integrity: sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==} + '@playwright/test@1.60.0': + resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==} engines: {node: '>=18'} hasBin: true @@ -2930,8 +2930,8 @@ packages: resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} engines: {node: '>= 0.4'} - get-tsconfig@4.13.7: - resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==} + get-tsconfig@4.14.0: + resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} @@ -3763,13 +3763,13 @@ packages: pkg-types@2.2.0: resolution: {integrity: sha512-2SM/GZGAEkPp3KWORxQZns4M+WSeXbC2HEvmOIJe3Cmiv6ieAJvdVhDldtHqM5J1Y7MrR1XhkBT/rMlhh9FdqQ==} - playwright-core@1.57.0: - resolution: {integrity: sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==} + playwright-core@1.60.0: + resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} engines: {node: '>=18'} hasBin: true - playwright@1.57.0: - resolution: {integrity: sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==} + playwright@1.60.0: + resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==} engines: {node: '>=18'} hasBin: true @@ -4841,10 +4841,10 @@ snapshots: '@ast-grep/napi-win32-ia32-msvc': 0.36.3 '@ast-grep/napi-win32-x64-msvc': 0.36.3 - '@axe-core/playwright@4.11.0(playwright-core@1.57.0)': + '@axe-core/playwright@4.11.0(playwright-core@1.60.0)': dependencies: axe-core: 4.11.0 - playwright-core: 1.57.0 + playwright-core: 1.60.0 '@babel/code-frame@7.27.1': dependencies: @@ -5538,9 +5538,9 @@ snapshots: '@pkgr/core@0.2.9': {} - '@playwright/test@1.57.0': + '@playwright/test@1.60.0': dependencies: - playwright: 1.57.0 + playwright: 1.60.0 '@reactflow/background@11.3.14(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: @@ -7379,7 +7379,7 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 - get-tsconfig@4.13.7: + get-tsconfig@4.14.0: dependencies: resolve-pkg-maps: 1.0.0 optional: true @@ -8262,11 +8262,11 @@ snapshots: exsolve: 1.0.7 pathe: 2.0.3 - playwright-core@1.57.0: {} + playwright-core@1.60.0: {} - playwright@1.57.0: + playwright@1.60.0: dependencies: - playwright-core: 1.57.0 + playwright-core: 1.60.0 optionalDependencies: fsevents: 2.3.2 @@ -9000,7 +9000,7 @@ snapshots: tsx@4.21.0: dependencies: esbuild: 0.27.7 - get-tsconfig: 4.13.7 + get-tsconfig: 4.14.0 optionalDependencies: fsevents: 2.3.3 optional: true From a1bd1ab85536f0071dfa48786741267b928711b4 Mon Sep 17 00:00:00 2001 From: Dominik Schlosser Date: Mon, 25 May 2026 08:12:28 +0200 Subject: [PATCH 5/5] Introduce mechanism for different trust material sources (#48869) closes #48269 Signed-off-by: Dominik Schlosser Signed-off-by: mposolda Co-authored-by: mposolda --- .../TrustMaterialIdentityProvider.java | 33 +++ .../broker/provider/TrustMaterialRequest.java | 73 +++++ .../util/IdentityProviderTypeUtil.java | 2 + .../keycloak/models/IdentityProviderType.java | 1 + .../AttestationBasedClientAuthenticator.java | 81 ++--- .../broker/oidc/OIDCIdentityProvider.java | 29 +- .../provider/TrustMaterialResolver.java | 77 +++++ .../trust/DefaultTrustIdentityProvider.java | 75 +++++ .../DefaultTrustIdentityProviderConfig.java | 79 +++++ .../DefaultTrustIdentityProviderFactory.java | 81 +++++ .../DefaultTrustMaterialPublicKeyLoader.java | 54 ++++ .../keycloak/broker/trust/TrustKeyUtil.java | 23 ++ .../keys/loader/PublicKeyStorageManager.java | 3 + .../TrustedAttestationKeysLoader.java | 22 +- .../protocol/oidc/utils/JWKSServerUtils.java | 37 ++- ...ak.broker.provider.IdentityProviderFactory | 3 +- .../TrustMaterialIdentityProviderTest.java | 280 ++++++++++++++++++ ...estationBasedClientAuthenticationTest.java | 60 +++- 18 files changed, 907 insertions(+), 106 deletions(-) create mode 100644 server-spi-private/src/main/java/org/keycloak/broker/provider/TrustMaterialIdentityProvider.java create mode 100644 server-spi-private/src/main/java/org/keycloak/broker/provider/TrustMaterialRequest.java create mode 100644 services/src/main/java/org/keycloak/broker/provider/TrustMaterialResolver.java create mode 100644 services/src/main/java/org/keycloak/broker/trust/DefaultTrustIdentityProvider.java create mode 100644 services/src/main/java/org/keycloak/broker/trust/DefaultTrustIdentityProviderConfig.java create mode 100644 services/src/main/java/org/keycloak/broker/trust/DefaultTrustIdentityProviderFactory.java create mode 100644 services/src/main/java/org/keycloak/broker/trust/DefaultTrustMaterialPublicKeyLoader.java create mode 100644 services/src/main/java/org/keycloak/broker/trust/TrustKeyUtil.java create mode 100644 tests/base/src/test/java/org/keycloak/tests/broker/trust/TrustMaterialIdentityProviderTest.java diff --git a/server-spi-private/src/main/java/org/keycloak/broker/provider/TrustMaterialIdentityProvider.java b/server-spi-private/src/main/java/org/keycloak/broker/provider/TrustMaterialIdentityProvider.java new file mode 100644 index 00000000000..66313ffc4e9 --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/broker/provider/TrustMaterialIdentityProvider.java @@ -0,0 +1,33 @@ +/* + * Copyright 2026 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.broker.provider; + +import java.util.stream.Stream; + +import org.keycloak.jose.jwk.JWK; +import org.keycloak.models.IdentityProviderModel; + +/** + * Identity providers that expose reusable trust material for flows such as + * client attestation or OID4VCI key attestation. + */ +public interface TrustMaterialIdentityProvider extends IdentityProvider { + + Stream resolveKeys(TrustMaterialRequest request); + +} diff --git a/server-spi-private/src/main/java/org/keycloak/broker/provider/TrustMaterialRequest.java b/server-spi-private/src/main/java/org/keycloak/broker/provider/TrustMaterialRequest.java new file mode 100644 index 00000000000..fbb2e647b9d --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/broker/provider/TrustMaterialRequest.java @@ -0,0 +1,73 @@ +/* + * Copyright 2026 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.broker.provider; + +public class TrustMaterialRequest { + + private final String kid; + private final String algorithm; + private final String issuer; + + private TrustMaterialRequest(Builder builder) { + this.kid = builder.kid; + this.algorithm = builder.algorithm; + this.issuer = builder.issuer; + } + + public String getKid() { + return kid; + } + + public String getAlgorithm() { + return algorithm; + } + + public String getIssuer() { + return issuer; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private String kid; + private String algorithm; + private String issuer; + + public Builder kid(String kid) { + this.kid = kid; + return this; + } + + public Builder algorithm(String algorithm) { + this.algorithm = algorithm; + return this; + } + + public Builder issuer(String issuer) { + this.issuer = issuer; + return this; + } + + public TrustMaterialRequest build() { + return new TrustMaterialRequest(this); + } + } +} diff --git a/server-spi-private/src/main/java/org/keycloak/broker/provider/util/IdentityProviderTypeUtil.java b/server-spi-private/src/main/java/org/keycloak/broker/provider/util/IdentityProviderTypeUtil.java index 32c8783f15b..d02ca899783 100644 --- a/server-spi-private/src/main/java/org/keycloak/broker/provider/util/IdentityProviderTypeUtil.java +++ b/server-spi-private/src/main/java/org/keycloak/broker/provider/util/IdentityProviderTypeUtil.java @@ -10,6 +10,7 @@ import org.keycloak.broker.provider.ClientAssertionIdentityProvider; import org.keycloak.broker.provider.ExchangeExternalToken; import org.keycloak.broker.provider.IdentityProvider; import org.keycloak.broker.provider.JWTAuthorizationGrantProvider; +import org.keycloak.broker.provider.TrustMaterialIdentityProvider; import org.keycloak.broker.provider.UserAuthenticationIdentityProvider; import org.keycloak.broker.social.SocialIdentityProvider; import org.keycloak.models.IdentityProviderCapability; @@ -80,6 +81,7 @@ public class IdentityProviderTypeUtil { return switch (type) { case USER_AUTHENTICATION -> UserAuthenticationIdentityProvider.class; case CLIENT_ASSERTION -> ClientAssertionIdentityProvider.class; + case TRUST_MATERIAL -> TrustMaterialIdentityProvider.class; case EXCHANGE_EXTERNAL_TOKEN -> ExchangeExternalToken.class; case JWT_AUTHORIZATION_GRANT -> JWTAuthorizationGrantProvider.class; case ANY -> IdentityProvider.class; diff --git a/server-spi/src/main/java/org/keycloak/models/IdentityProviderType.java b/server-spi/src/main/java/org/keycloak/models/IdentityProviderType.java index 9a4f3177448..1e3ce4ac86b 100644 --- a/server-spi/src/main/java/org/keycloak/models/IdentityProviderType.java +++ b/server-spi/src/main/java/org/keycloak/models/IdentityProviderType.java @@ -12,6 +12,7 @@ public enum IdentityProviderType { ANY, USER_AUTHENTICATION(USER_LINKING), CLIENT_ASSERTION, + TRUST_MATERIAL, EXCHANGE_EXTERNAL_TOKEN(USER_LINKING), JWT_AUTHORIZATION_GRANT(USER_LINKING); diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/client/AttestationBasedClientAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/client/AttestationBasedClientAuthenticator.java index 981a627d6a9..3feba75c6d9 100644 --- a/services/src/main/java/org/keycloak/authentication/authenticators/client/AttestationBasedClientAuthenticator.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/client/AttestationBasedClientAuthenticator.java @@ -34,6 +34,8 @@ import org.keycloak.Config; import org.keycloak.TokenVerifier; import org.keycloak.authentication.AuthenticationFlowError; import org.keycloak.authentication.ClientAuthenticationFlowContext; +import org.keycloak.broker.provider.TrustMaterialRequest; +import org.keycloak.broker.provider.TrustMaterialResolver; import org.keycloak.common.Profile; import org.keycloak.common.util.Base64Url; import org.keycloak.crypto.KeyUse; @@ -46,7 +48,6 @@ import org.keycloak.jose.jwk.JWKParser; import org.keycloak.jose.jws.Algorithm; import org.keycloak.jose.jws.JWSInput; import org.keycloak.models.AuthenticationExecutionModel; -import org.keycloak.models.AuthenticatorConfigModel; import org.keycloak.models.ClientModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; @@ -58,7 +59,6 @@ import org.keycloak.provider.ProviderConfigProperty; import org.keycloak.representations.JsonWebToken; import org.keycloak.saml.RandomSecret; import org.keycloak.services.ServicesLogger; -import org.keycloak.util.JsonSerialization; import org.keycloak.util.Strings; import org.keycloak.wellknown.WellKnownProvider; @@ -92,20 +92,9 @@ public class AttestationBasedClientAuthenticator extends AbstractClientAuthentic public static final String OAUTH_CLIENT_ATTESTATION_POP_JWT_TYPE = "oauth-client-attestation-pop+jwt"; /** - * The ClientAuthenticator needs to be aware of the public keys from the various Attesters it can trust. - * - * [ - * { - * "kty": "RSA", - * "kid": "openid-abca-attester-key", - * "use": "sig", - * "alg": "PS256", - * "n": "uVd8mEqXMp...aaVZNQ", - * "e": "AQAB" - * } - * ] + * Comma-separated aliases of trust-material identity providers that expose the trusted attester keys. */ - public static final String OAUTH_CLIENT_ATTESTATION_CONFIG_ATTESTER_JWKS = "attester_jwks"; + public static final String OAUTH_CLIENT_ATTESTATION_CONFIG_TRUST_IDPS = "attester_trust_idps"; @Override public String getId() { @@ -172,22 +161,23 @@ public class AttestationBasedClientAuthenticator extends AbstractClientAuthentic @Override public boolean isConfigurable() { - return true; + return false; } @Override public List getConfigProperties() { - ProviderConfigProperty jwks = new ProviderConfigProperty(); - jwks.setName(OAUTH_CLIENT_ATTESTATION_CONFIG_ATTESTER_JWKS); - jwks.setLabel("Attester JWKS"); - jwks.setType(ProviderConfigProperty.TEXT_TYPE); - jwks.setHelpText("JWKS containing trusted attester public keys"); - return List.of(jwks); + return List.of(); } @Override public List getConfigPropertiesPerClient() { - return List.of(); + ProviderConfigProperty trustIdps = new ProviderConfigProperty(); + trustIdps.setName(OAUTH_CLIENT_ATTESTATION_CONFIG_TRUST_IDPS); + trustIdps.setLabel("Attester trust identity providers"); + trustIdps.setType(ProviderConfigProperty.STRING_TYPE); + trustIdps.setRequired(true); + trustIdps.setHelpText("Comma-separated aliases of trust-material identity providers containing trusted attester public keys"); + return List.of(trustIdps); } @Override @@ -314,24 +304,23 @@ public class AttestationBasedClientAuthenticator extends AbstractClientAuthentic // Private --------------------------------------------------------------------------------------------------------- - private KeyWrapper findAttesterKey(ClientAuthenticationFlowContext context, String kid) { + private KeyWrapper findAttesterKey(ClientAuthenticationFlowContext context, String kid, String algorithm, String issuer) { if (Strings.isEmpty(kid)) throw new IllegalArgumentException("Invalid attester kid: " + kid); - AuthenticatorConfigModel configModel = context.getRealm().getAuthenticatorConfigByAlias(PROVIDER_ID); - if (configModel == null) - throw new IllegalStateException("No config for client authenticator: " + PROVIDER_ID); + String configValue = Optional.ofNullable(context.getClient()) + .map(client -> client.getAttribute(OAUTH_CLIENT_ATTESTATION_CONFIG_TRUST_IDPS)) + .orElse(null); + if (Strings.isEmpty(configValue)) + throw new IllegalStateException("Cannot load attester keys: " + OAUTH_CLIENT_ATTESTATION_CONFIG_TRUST_IDPS); - String configValue = Optional.ofNullable(configModel.getConfig()).orElse(Map.of()) - .get(OAUTH_CLIENT_ATTESTATION_CONFIG_ATTESTER_JWKS); - if (configValue == null) - throw new IllegalStateException("Cannot load attester keys: " + OAUTH_CLIENT_ATTESTATION_CONFIG_ATTESTER_JWKS); - - ABCAConfig attesterKeys = JsonSerialization.valueFromString(configValue, ABCAConfig.class); - JWK jwk = attesterKeys.getKeys().stream() - .filter(k -> kid.equals(k.getKeyId())) - .findAny() + TrustMaterialRequest request = TrustMaterialRequest.builder() + .kid(kid) + .algorithm(algorithm) + .issuer(issuer) + .build(); + JWK jwk = new TrustMaterialResolver().resolveKey(context.getSession(), configValue, request) .orElseThrow(() -> new IllegalStateException("No matching key found for kid: " + kid)); return toPublicKeyWrapper(jwk); @@ -401,7 +390,7 @@ public class AttestationBasedClientAuthenticator extends AbstractClientAuthentic // The signature of the Client Attestation JWT verifies with the public key of a known and trusted Attester // - KeyWrapper attesterKey = findAttesterKey(context, jws.getHeader().getKeyId()); + KeyWrapper attesterKey = findAttesterKey(context, jws.getHeader().getKeyId(), jws.getHeader().getRawAlgorithm(), attestationJwt.getIssuer()); // Client Attestation JWT verification without signature check // @@ -507,24 +496,6 @@ public class AttestationBasedClientAuthenticator extends AbstractClientAuthentic // [TODO] Additional checks to guarantee replay protection for the Client Attestation PoP JWT might need to be applied } - /** - * The AttestationBasedClientAuthenticator config - */ - public static class ABCAConfig { - - @JsonProperty - private List keys; - - public List getKeys() { - return keys; - } - - public ABCAConfig setKeys(List keys) { - this.keys = keys; - return this; - } - } - public static class ABCAResult { /** diff --git a/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java b/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java index d839f477f3f..91062fd26a4 100755 --- a/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java +++ b/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java @@ -24,6 +24,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.stream.Stream; import jakarta.ws.rs.GET; import jakarta.ws.rs.Path; @@ -47,6 +48,9 @@ import org.keycloak.broker.provider.ClientAssertionIdentityProvider; import org.keycloak.broker.provider.ExchangeExternalToken; import org.keycloak.broker.provider.IdentityBrokerException; import org.keycloak.broker.provider.JWTAuthorizationGrantProvider; +import org.keycloak.broker.provider.TrustMaterialIdentityProvider; +import org.keycloak.broker.provider.TrustMaterialRequest; +import org.keycloak.broker.trust.TrustKeyUtil; import org.keycloak.common.Profile; import org.keycloak.common.util.Base64Url; import org.keycloak.common.util.SecretGenerator; @@ -65,6 +69,7 @@ import org.keycloak.jose.JOSE; import org.keycloak.jose.JOSEParser; import org.keycloak.jose.jwe.JWE; import org.keycloak.jose.jwe.JWEException; +import org.keycloak.jose.jwk.JWK; import org.keycloak.jose.jws.JWSInput; import org.keycloak.keys.PublicKeyStorageProvider; import org.keycloak.keys.PublicKeyStorageUtils; @@ -80,6 +85,7 @@ import org.keycloak.models.UserSessionModel; import org.keycloak.protocol.oidc.JWTAuthorizationGrantValidationContext; import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.TokenExchangeContext; +import org.keycloak.protocol.oidc.utils.JWKSServerUtils; import org.keycloak.representations.AccessTokenResponse; import org.keycloak.representations.IDToken; import org.keycloak.representations.JsonWebToken; @@ -92,6 +98,7 @@ import org.keycloak.services.resources.RealmsResource; import org.keycloak.sessions.AuthenticationSessionModel; import org.keycloak.util.Booleans; import org.keycloak.util.JsonSerialization; +import org.keycloak.util.Strings; import org.keycloak.util.TokenUtil; import org.keycloak.vault.VaultStringSecret; @@ -101,7 +108,7 @@ import org.jboss.logging.Logger; /** * @author Pedro Igor */ -public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider implements ExchangeExternalToken, ClientAssertionIdentityProvider, JWTAuthorizationGrantProvider { +public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider implements ExchangeExternalToken, ClientAssertionIdentityProvider, JWTAuthorizationGrantProvider, TrustMaterialIdentityProvider { protected static final Logger logger = Logger.getLogger(OIDCIdentityProvider.class); public static final String SCOPE_OPENID = "openid"; @@ -1086,6 +1093,18 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider resolveKeys(TrustMaterialRequest request) { + if (!matchesTrustMaterialIssuer(request)) { + return Stream.empty(); + } + + Stream keys = Stream.ofNullable(PublicKeyStorageManager.getIdentityProviderKeyWrapper(session, + session.getContext().getRealm(), getConfig(), request.getKid(), request.getAlgorithm())); + + return TrustKeyUtil.filterKeys(keys.map(JWKSServerUtils::toJwk), request); + } + @Override protected void setEmailVerified(UserModel user, BrokeredIdentityContext context) { OIDCIdentityProviderConfig config = getConfig(); @@ -1103,6 +1122,14 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider resolveKeys(KeycloakSession session, String aliases, TrustMaterialRequest request) { + if (Strings.isEmpty(aliases)) { + return Stream.empty(); + } + return resolveKeys(session, splitAliases(aliases), request); + } + + public Stream resolveKeys(KeycloakSession session, Collection aliases, TrustMaterialRequest request) { + if (aliases == null || aliases.isEmpty()) { + return Stream.empty(); + } + + return aliases.stream() + .filter(Objects::nonNull) + .map(String::trim) + .filter(alias -> !alias.isEmpty()) + .map(alias -> resolveProvider(session, alias)) + .flatMap(Optional::stream) + .flatMap(provider -> provider.resolveKeys(request)); + } + + public Optional resolveKey(KeycloakSession session, String aliases, TrustMaterialRequest request) { + return resolveKeys(session, aliases, request).findFirst(); + } + + private Optional> resolveProvider(KeycloakSession session, String alias) { + IdentityProviderModel model = session.identityProviders().getByAlias(alias); + if (model == null || !model.isEnabled()) { + return Optional.empty(); + } + + TrustMaterialIdentityProvider provider = IdentityBrokerService.getIdentityProvider(session, model, TrustMaterialIdentityProvider.class); + return Optional.ofNullable(provider); + } + + private List splitAliases(String aliases) { + return Arrays.stream(aliases.split(",")) + .filter(Objects::nonNull) + .map(String::trim) + .filter(alias -> !alias.isEmpty()) + .toList(); + } +} diff --git a/services/src/main/java/org/keycloak/broker/trust/DefaultTrustIdentityProvider.java b/services/src/main/java/org/keycloak/broker/trust/DefaultTrustIdentityProvider.java new file mode 100644 index 00000000000..7c8b41ee7ce --- /dev/null +++ b/services/src/main/java/org/keycloak/broker/trust/DefaultTrustIdentityProvider.java @@ -0,0 +1,75 @@ +/* + * Copyright 2026 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.broker.trust; + +import java.util.stream.Stream; + +import org.keycloak.broker.provider.TrustMaterialIdentityProvider; +import org.keycloak.broker.provider.TrustMaterialRequest; +import org.keycloak.crypto.KeyWrapper; +import org.keycloak.jose.jwk.JWK; +import org.keycloak.keys.PublicKeyLoader; +import org.keycloak.keys.PublicKeyStorageProvider; +import org.keycloak.keys.PublicKeyStorageUtils; +import org.keycloak.models.KeycloakSession; +import org.keycloak.protocol.oidc.utils.JWKSServerUtils; +import org.keycloak.util.Strings; +import org.keycloak.utils.StringUtil; + +public class DefaultTrustIdentityProvider implements TrustMaterialIdentityProvider { + + private final KeycloakSession session; + private final DefaultTrustIdentityProviderConfig config; + + public DefaultTrustIdentityProvider(KeycloakSession session, DefaultTrustIdentityProviderConfig config) { + this.session = session; + this.config = config; + } + + @Override + public DefaultTrustIdentityProviderConfig getConfig() { + return config; + } + + @Override + public Stream resolveKeys(TrustMaterialRequest request) { + PublicKeyLoader loader = new DefaultTrustMaterialPublicKeyLoader(session, config); + PublicKeyStorageProvider keyStorage = session.getProvider(PublicKeyStorageProvider.class); + String modelKey = PublicKeyStorageUtils.getIdpModelCacheKey(session.getContext().getRealm().getId(), config.getInternalId()); + Stream keys = Strings.isEmpty(request.getKid()) + ? keyStorage.getKeys(modelKey, loader).stream() + : Stream.of(keyStorage.getPublicKey(modelKey, request.getKid(), request.getAlgorithm(), loader)); + + return TrustKeyUtil.filterKeys(keys.map(JWKSServerUtils::toJwk), request); + } + + @Override + public boolean reloadKeys() { + if (!config.isEnabled() || StringUtil.isBlank(config.getTrustedJwksUrl())) { + return false; + } + + PublicKeyStorageProvider keyStorage = session.getProvider(PublicKeyStorageProvider.class); + String modelKey = PublicKeyStorageUtils.getIdpModelCacheKey(session.getContext().getRealm().getId(), config.getInternalId()); + return keyStorage.reloadKeys(modelKey, new DefaultTrustMaterialPublicKeyLoader(session, config)); + } + + @Override + public void close() { + } +} diff --git a/services/src/main/java/org/keycloak/broker/trust/DefaultTrustIdentityProviderConfig.java b/services/src/main/java/org/keycloak/broker/trust/DefaultTrustIdentityProviderConfig.java new file mode 100644 index 00000000000..cd2283d1192 --- /dev/null +++ b/services/src/main/java/org/keycloak/broker/trust/DefaultTrustIdentityProviderConfig.java @@ -0,0 +1,79 @@ +/* + * Copyright 2026 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.broker.trust; + +import org.keycloak.models.IdentityProviderModel; +import org.keycloak.models.RealmModel; +import org.keycloak.util.Strings; + +import static org.keycloak.common.util.UriUtils.checkUrl; + +public class DefaultTrustIdentityProviderConfig extends IdentityProviderModel { + + public static final String TRUSTED_JWKS_URL = "trustedJwksUrl"; + public static final String TRUSTED_JWKS = "trustedJwks"; + + public DefaultTrustIdentityProviderConfig() { + } + + public DefaultTrustIdentityProviderConfig(IdentityProviderModel model) { + super(model); + } + + @Override + public Boolean isHideOnLogin() { + return true; + } + + @Override + public void validate(RealmModel realm) { + super.validate(realm); + boolean hasTrustedJwksUrl = !Strings.isEmpty(getTrustedJwksUrl()); + boolean hasTrustedJwks = !Strings.isEmpty(getTrustedJwks()); + if (hasTrustedJwksUrl == hasTrustedJwks) { + throw new IllegalArgumentException("Configure exactly one of trusted JWKS URL or trusted JWKS"); + } + if (hasTrustedJwksUrl) { + checkUrl(realm.getSslRequired(), getTrustedJwksUrl(), TRUSTED_JWKS_URL); + } + } + + public String getTrustedJwksUrl() { + return getConfig().get(TRUSTED_JWKS_URL); + } + + public void setTrustedJwksUrl(String trustedJwksUrl) { + if (trustedJwksUrl == null) { + getConfig().remove(TRUSTED_JWKS_URL); + } else { + getConfig().put(TRUSTED_JWKS_URL, trustedJwksUrl); + } + } + + public String getTrustedJwks() { + return getConfig().get(TRUSTED_JWKS); + } + + public void setTrustedJwks(String trustedJwks) { + if (trustedJwks == null) { + getConfig().remove(TRUSTED_JWKS); + } else { + getConfig().put(TRUSTED_JWKS, trustedJwks); + } + } +} diff --git a/services/src/main/java/org/keycloak/broker/trust/DefaultTrustIdentityProviderFactory.java b/services/src/main/java/org/keycloak/broker/trust/DefaultTrustIdentityProviderFactory.java new file mode 100644 index 00000000000..16b01ebd665 --- /dev/null +++ b/services/src/main/java/org/keycloak/broker/trust/DefaultTrustIdentityProviderFactory.java @@ -0,0 +1,81 @@ +/* + * Copyright 2026 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.broker.trust; + +import java.util.List; +import java.util.Map; + +import org.keycloak.Config; +import org.keycloak.broker.provider.AbstractIdentityProviderFactory; +import org.keycloak.common.Profile; +import org.keycloak.models.IdentityProviderModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.provider.EnvironmentDependentProviderFactory; +import org.keycloak.provider.ProviderConfigProperty; + +public class DefaultTrustIdentityProviderFactory extends AbstractIdentityProviderFactory implements EnvironmentDependentProviderFactory { + + public static final String PROVIDER_ID = "default-trust"; + + @Override + public String getName() { + return "Default Trust"; + } + + @Override + public DefaultTrustIdentityProvider create(KeycloakSession session, IdentityProviderModel model) { + return new DefaultTrustIdentityProvider(session, new DefaultTrustIdentityProviderConfig(model)); + } + + @Override + public Map parseConfig(KeycloakSession session, String config) { + throw new UnsupportedOperationException(); + } + + @Override + public IdentityProviderModel createConfig() { + return new DefaultTrustIdentityProviderConfig(); + } + + @Override + public List getConfigProperties() { + ProviderConfigProperty trustedJwksUrl = new ProviderConfigProperty(); + trustedJwksUrl.setName(DefaultTrustIdentityProviderConfig.TRUSTED_JWKS_URL); + trustedJwksUrl.setLabel("Trusted JWKS URL"); + trustedJwksUrl.setHelpText("External JWKS URL containing trusted signing keys."); + trustedJwksUrl.setType(ProviderConfigProperty.STRING_TYPE); + + ProviderConfigProperty trustedJwks = new ProviderConfigProperty(); + trustedJwks.setName(DefaultTrustIdentityProviderConfig.TRUSTED_JWKS); + trustedJwks.setLabel("Trusted JWKS"); + trustedJwks.setHelpText("Hardcoded JWKS containing trusted signing keys."); + trustedJwks.setType(ProviderConfigProperty.TEXT_TYPE); + + return List.of(trustedJwksUrl, trustedJwks); + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public boolean isSupported(Config.Scope config) { + return Profile.isFeatureEnabled(Profile.Feature.OID4VC_VCI) || Profile.isFeatureEnabled(Profile.Feature.CLIENT_AUTH_ABCA); + } +} diff --git a/services/src/main/java/org/keycloak/broker/trust/DefaultTrustMaterialPublicKeyLoader.java b/services/src/main/java/org/keycloak/broker/trust/DefaultTrustMaterialPublicKeyLoader.java new file mode 100644 index 00000000000..901f92f806e --- /dev/null +++ b/services/src/main/java/org/keycloak/broker/trust/DefaultTrustMaterialPublicKeyLoader.java @@ -0,0 +1,54 @@ +/* + * Copyright 2026 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.broker.trust; + +import org.keycloak.crypto.PublicKeysWrapper; +import org.keycloak.jose.jwk.JSONWebKeySet; +import org.keycloak.jose.jwk.JWK; +import org.keycloak.keys.PublicKeyLoader; +import org.keycloak.models.KeycloakSession; +import org.keycloak.protocol.oidc.utils.JWKSHttpUtils; +import org.keycloak.util.JWKSUtils; +import org.keycloak.util.JsonSerialization; +import org.keycloak.utils.StringUtil; + +public class DefaultTrustMaterialPublicKeyLoader implements PublicKeyLoader { + + private final KeycloakSession session; + private final DefaultTrustIdentityProviderConfig config; + + public DefaultTrustMaterialPublicKeyLoader(KeycloakSession session, DefaultTrustIdentityProviderConfig config) { + this.session = session; + this.config = config; + } + + @Override + public PublicKeysWrapper loadKeys() throws Exception { + if (StringUtil.isNotBlank(config.getTrustedJwksUrl())) { + JSONWebKeySet jwks = JWKSHttpUtils.sendJwksRequest(session, config.getTrustedJwksUrl()); + return JWKSUtils.getKeyWrappersForUse(jwks, JWK.Use.SIG, true); + } + + if (StringUtil.isNotBlank(config.getTrustedJwks())) { + JSONWebKeySet jwks = JsonSerialization.readValue(config.getTrustedJwks(), JSONWebKeySet.class); + return JWKSUtils.getKeyWrappersForUse(jwks, JWK.Use.SIG, true); + } + + return PublicKeysWrapper.EMPTY; + } +} diff --git a/services/src/main/java/org/keycloak/broker/trust/TrustKeyUtil.java b/services/src/main/java/org/keycloak/broker/trust/TrustKeyUtil.java new file mode 100644 index 00000000000..030603ef9f4 --- /dev/null +++ b/services/src/main/java/org/keycloak/broker/trust/TrustKeyUtil.java @@ -0,0 +1,23 @@ +package org.keycloak.broker.trust; + +import java.util.Objects; +import java.util.stream.Stream; + +import org.keycloak.broker.provider.TrustMaterialRequest; +import org.keycloak.jose.jwk.JWK; +import org.keycloak.util.Strings; + +public class TrustKeyUtil { + + private TrustKeyUtil() { + } + + public static Stream filterKeys(Stream keys, TrustMaterialRequest request) { + return keys + .filter(Objects::nonNull) + .filter(key -> Strings.isEmpty(request.getKid()) || Objects.equals(request.getKid(), key.getKeyId())) + .filter(key -> Strings.isEmpty(request.getAlgorithm()) || Strings.isEmpty(key.getAlgorithm()) + || Objects.equals(request.getAlgorithm(), key.getAlgorithm())) + .filter(key -> Strings.isEmpty(key.getPublicKeyUse()) || Objects.equals(JWK.Use.SIG.asString(), key.getPublicKeyUse())); + } +} diff --git a/services/src/main/java/org/keycloak/keys/loader/PublicKeyStorageManager.java b/services/src/main/java/org/keycloak/keys/loader/PublicKeyStorageManager.java index dcf9e02e878..33e7d7c43a8 100644 --- a/services/src/main/java/org/keycloak/keys/loader/PublicKeyStorageManager.java +++ b/services/src/main/java/org/keycloak/keys/loader/PublicKeyStorageManager.java @@ -68,7 +68,10 @@ public class PublicKeyStorageManager { public static KeyWrapper getIdentityProviderKeyWrapper(KeycloakSession session, RealmModel realm, JWTAuthorizationGrantConfig idpConfig, JWSInput input) { String kid = input.getHeader().getKeyId(); String alg = input.getHeader().getRawAlgorithm(); + return getIdentityProviderKeyWrapper(session, realm, idpConfig, kid, alg); + } + public static KeyWrapper getIdentityProviderKeyWrapper(KeycloakSession session, RealmModel realm, JWTAuthorizationGrantConfig idpConfig, String kid, String alg) { PublicKeyStorageProvider keyStorage = session.getProvider(PublicKeyStorageProvider.class); String modelKey = PublicKeyStorageUtils.getIdpModelCacheKey(realm.getId(), idpConfig.getInternalId()); diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/TrustedAttestationKeysLoader.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/TrustedAttestationKeysLoader.java index b71cb4b948d..96db7ed9850 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/TrustedAttestationKeysLoader.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/TrustedAttestationKeysLoader.java @@ -1,23 +1,18 @@ package org.keycloak.protocol.oid4vc.issuance.keybinding; import java.io.IOException; -import java.security.cert.X509Certificate; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Objects; -import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; import java.util.stream.Stream; import org.keycloak.constants.OID4VCIConstants; -import org.keycloak.crypto.KeyType; import org.keycloak.jose.jwk.JSONWebKeySet; import org.keycloak.jose.jwk.JWK; -import org.keycloak.jose.jwk.JWKBuilder; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; import org.keycloak.protocol.oidc.utils.JWKSServerUtils; @@ -132,22 +127,7 @@ public final class TrustedAttestationKeysLoader { .filter(key -> keyIds.contains(key.getKid()) && key.getPublicKey() != null) .forEach(key -> { try { - JWKBuilder builder = JWKBuilder.create() - .kid(key.getKid()) - .algorithm(key.getAlgorithmOrDefault()); - List certificates = Optional.ofNullable(key.getCertificateChain()) - .filter(certs -> !certs.isEmpty()) - .orElseGet(() -> Optional.ofNullable(key.getCertificate()) - .map(Collections::singletonList) - .orElseGet(Collections::emptyList)); - JWK jwk = null; - if (Objects.equals(key.getType(), KeyType.RSA)) { - jwk = builder.rsa(key.getPublicKey(), certificates, key.getUse()); - } else if (Objects.equals(key.getType(), KeyType.EC)) { - jwk = builder.ec(key.getPublicKey(), certificates, key.getUse()); - } else if (Objects.equals(key.getType(), KeyType.OKP)) { - jwk = builder.okp(key.getPublicKey(), key.getUse()); - } + JWK jwk = JWKSServerUtils.toJwk(key); if (jwk != null) { keyMap.put(key.getKid(), jwk); } else { diff --git a/services/src/main/java/org/keycloak/protocol/oidc/utils/JWKSServerUtils.java b/services/src/main/java/org/keycloak/protocol/oidc/utils/JWKSServerUtils.java index bbcdd38bce0..ffdef3db490 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/utils/JWKSServerUtils.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/utils/JWKSServerUtils.java @@ -23,6 +23,7 @@ import java.util.Objects; import java.util.Optional; import org.keycloak.crypto.KeyType; +import org.keycloak.crypto.KeyWrapper; import org.keycloak.jose.jwk.JSONWebKeySet; import org.keycloak.jose.jwk.JWK; import org.keycloak.jose.jwk.JWKBuilder; @@ -36,21 +37,7 @@ import org.keycloak.models.RealmModel; public static JSONWebKeySet getRealmJwks(KeycloakSession session, RealmModel realm){ JWK[] jwks = session.keys().getKeysStream(realm) .filter(k -> k.getStatus().isEnabled() && k.getPublicKey() != null) - .map(k -> { - JWKBuilder b = JWKBuilder.create().kid(k.getKid()).algorithm(k.getAlgorithmOrDefault()); - List certificates = Optional.ofNullable(k.getCertificateChain()) - .filter(certs -> !certs.isEmpty()) - .orElseGet(() -> Optional.ofNullable(k.getCertificate()).map(Collections::singletonList) - .orElseGet(Collections::emptyList)); - if (k.getType().equals(KeyType.RSA)) { - return b.rsa(k.getPublicKey(), certificates, k.getUse()); - } else if (k.getType().equals(KeyType.EC)) { - return b.ec(k.getPublicKey(), certificates, k.getUse()); - } else if (k.getType().equals(KeyType.OKP)) { - return b.okp(k.getPublicKey(), k.getUse()); - } - return null; - }) + .map(JWKSServerUtils::toJwk) .filter(Objects::nonNull) .toArray(JWK[]::new); @@ -58,4 +45,24 @@ import org.keycloak.models.RealmModel; keySet.setKeys(jwks); return keySet; } + + + public static JWK toJwk(KeyWrapper key) { + JWKBuilder b = JWKBuilder.create() + .kid(key.getKid()) + .algorithm(key.getAlgorithmOrDefault()); + List certificates = Optional.ofNullable(key.getCertificateChain()) + .filter(certs -> !certs.isEmpty()) + .orElseGet(() -> Optional.ofNullable(key.getCertificate()) + .map(Collections::singletonList) + .orElseGet(Collections::emptyList)); + if (key.getType().equals(KeyType.RSA)) { + return b.rsa(key.getPublicKey(), certificates, key.getUse()); + } else if (key.getType().equals(KeyType.EC)) { + return b.ec(key.getPublicKey(), certificates, key.getUse()); + } else if (key.getType().equals(KeyType.OKP)) { + return b.okp(key.getPublicKey(), key.getUse()); + } + return null; + } } diff --git a/services/src/main/resources/META-INF/services/org.keycloak.broker.provider.IdentityProviderFactory b/services/src/main/resources/META-INF/services/org.keycloak.broker.provider.IdentityProviderFactory index b8cea42fac3..fcff135eef7 100755 --- a/services/src/main/resources/META-INF/services/org.keycloak.broker.provider.IdentityProviderFactory +++ b/services/src/main/resources/META-INF/services/org.keycloak.broker.provider.IdentityProviderFactory @@ -21,4 +21,5 @@ org.keycloak.broker.saml.SAMLIdentityProviderFactory org.keycloak.broker.oauth.OAuth2IdentityProviderFactory org.keycloak.broker.spiffe.SpiffeIdentityProviderFactory org.keycloak.broker.kubernetes.KubernetesIdentityProviderFactory -org.keycloak.broker.jwtauthorizationgrant.JWTAuthorizationGrantIdentityProviderFactory \ No newline at end of file +org.keycloak.broker.jwtauthorizationgrant.JWTAuthorizationGrantIdentityProviderFactory +org.keycloak.broker.trust.DefaultTrustIdentityProviderFactory diff --git a/tests/base/src/test/java/org/keycloak/tests/broker/trust/TrustMaterialIdentityProviderTest.java b/tests/base/src/test/java/org/keycloak/tests/broker/trust/TrustMaterialIdentityProviderTest.java new file mode 100644 index 00000000000..151411bcaf2 --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/broker/trust/TrustMaterialIdentityProviderTest.java @@ -0,0 +1,280 @@ +/* + * Copyright 2026 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.tests.broker.trust; + +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.keycloak.broker.jwtauthorizationgrant.JWTAuthorizationGrantConfig; +import org.keycloak.broker.oidc.OIDCIdentityProvider; +import org.keycloak.broker.oidc.OIDCIdentityProviderConfig; +import org.keycloak.broker.oidc.OIDCIdentityProviderFactory; +import org.keycloak.broker.provider.TrustMaterialIdentityProvider; +import org.keycloak.broker.provider.TrustMaterialRequest; +import org.keycloak.broker.provider.TrustMaterialResolver; +import org.keycloak.broker.trust.DefaultTrustIdentityProvider; +import org.keycloak.broker.trust.DefaultTrustIdentityProviderConfig; +import org.keycloak.broker.trust.DefaultTrustIdentityProviderFactory; +import org.keycloak.common.Profile; +import org.keycloak.common.util.PemUtils; +import org.keycloak.jose.jwk.JSONWebKeySet; +import org.keycloak.jose.jwk.JWK; +import org.keycloak.jose.jwk.JWKBuilder; +import org.keycloak.models.IdentityProviderModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.services.resources.IdentityBrokerService; +import org.keycloak.testframework.annotations.InjectHttpServer; +import org.keycloak.testframework.annotations.KeycloakIntegrationTest; +import org.keycloak.testframework.annotations.TestSetup; +import org.keycloak.testframework.remote.runonserver.InjectRunOnServer; +import org.keycloak.testframework.remote.runonserver.RunOnServerClient; +import org.keycloak.testframework.server.KeycloakServerConfig; +import org.keycloak.testframework.server.KeycloakServerConfigBuilder; +import org.keycloak.testframework.util.HttpServerUtil; +import org.keycloak.util.JsonSerialization; + +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@KeycloakIntegrationTest(config = TrustMaterialIdentityProviderTest.TrustMaterialServerConfig.class) +public class TrustMaterialIdentityProviderTest { + + private static final String DEFAULT_INLINE_ALIAS = "trust-material-default-inline"; + private static final String DEFAULT_URL_ALIAS = "trust-material-default-url"; + private static final String DEFAULT_DISABLED_ALIAS = "trust-material-default-disabled"; + private static final String OIDC_ALIAS = "trust-material-oidc"; + private static final String KEY_ID = "trust-material-key"; + private static final String ALGORITHM = "PS256"; + private static final String ISSUER = "https://issuer.example.test"; + + private static String trustedJwks; + private static String trustedPublicKey; + + @InjectRunOnServer + RunOnServerClient runOnServer; + + @InjectHttpServer + HttpServer httpServer; + + @TestSetup + public void setup() throws Exception { + KeyPair key = createRsaKeyPair(); + JWK jwk = JWKBuilder.create() + .kid(KEY_ID) + .algorithm(ALGORITHM) + .rsa(key.getPublic()); + JSONWebKeySet jwks = new JSONWebKeySet(); + jwks.setKeys(new JWK[] { jwk }); + trustedJwks = JsonSerialization.writeValueAsString(jwks); + trustedPublicKey = PemUtils.encodeKey(key.getPublic()); + } + + @BeforeEach + public void configureIdentityProviders() { + String jwks = trustedJwks; + String publicKey = trustedPublicKey; + runOnServer.run(session -> { + RealmModel realm = session.getContext().getRealm(); + configureTrustIdentityProvider(realm, DEFAULT_INLINE_ALIAS, DefaultTrustIdentityProviderFactory.PROVIDER_ID, true, + Map.of(DefaultTrustIdentityProviderConfig.TRUSTED_JWKS, jwks)); + configureTrustIdentityProvider(realm, DEFAULT_DISABLED_ALIAS, DefaultTrustIdentityProviderFactory.PROVIDER_ID, false, + Map.of(DefaultTrustIdentityProviderConfig.TRUSTED_JWKS, jwks)); + configureTrustIdentityProvider(realm, OIDC_ALIAS, OIDCIdentityProviderFactory.PROVIDER_ID, true, + Map.of( + OIDCIdentityProviderConfig.USE_JWKS_URL, Boolean.FALSE.toString(), + JWTAuthorizationGrantConfig.PUBLIC_KEY_SIGNATURE_VERIFIER, publicKey, + JWTAuthorizationGrantConfig.PUBLIC_KEY_SIGNATURE_VERIFIER_KEY_ID, KEY_ID, + IdentityProviderModel.ISSUER, ISSUER)); + }); + } + + @Test + public void defaultTrustIdentityProviderResolvesInlineTrustedJwks() { + runOnServer.run(session -> { + TrustMaterialIdentityProvider provider = getTrustMaterialProvider(session.getContext().getRealm(), session, DEFAULT_INLINE_ALIAS); + assertInstanceOf(DefaultTrustIdentityProvider.class, provider); + + JWK jwk = provider.resolveKeys(matchingRequest()).findFirst().orElseThrow(); + + assertEquals(KEY_ID, jwk.getKeyId()); + assertEquals(ALGORITHM, jwk.getAlgorithm()); + }); + } + + @Test + public void defaultTrustIdentityProviderResolvesTrustedJwksUrl() { + String path = "/trust-material-jwks"; + httpServer.createContext(path, exchange -> HttpServerUtil.sendResponse(exchange, 200, + Map.of("Content-Type", List.of("application/json")), trustedJwks)); + + try { + String jwksUrl = "http://" + httpServer.getAddress().getHostString() + ":" + httpServer.getAddress().getPort() + path; + runOnServer.run(session -> configureTrustIdentityProvider(session.getContext().getRealm(), DEFAULT_URL_ALIAS, + DefaultTrustIdentityProviderFactory.PROVIDER_ID, true, + Map.of(DefaultTrustIdentityProviderConfig.TRUSTED_JWKS_URL, jwksUrl))); + + runOnServer.run(session -> { + TrustMaterialIdentityProvider provider = getTrustMaterialProvider(session.getContext().getRealm(), session, DEFAULT_URL_ALIAS); + assertInstanceOf(DefaultTrustIdentityProvider.class, provider); + + JWK jwk = provider.resolveKeys(matchingRequest()).findFirst().orElseThrow(); + + assertEquals(KEY_ID, jwk.getKeyId()); + assertEquals(ALGORITHM, jwk.getAlgorithm()); + }); + } finally { + httpServer.removeContext(path); + } + } + + @Test + public void trustMaterialResolverUsesEnabledProviderFromAliasList() { + runOnServer.run(session -> { + Optional jwk = new TrustMaterialResolver().resolveKey(session, + "missing-alias, " + DEFAULT_DISABLED_ALIAS + ", " + DEFAULT_INLINE_ALIAS, matchingRequest()); + + assertTrue(jwk.isPresent()); + assertEquals(KEY_ID, jwk.get().getKeyId()); + assertEquals(ALGORITHM, jwk.get().getAlgorithm()); + }); + } + + @Test + public void trustMaterialResolverReturnsEmptyForDisabledProvider() { + runOnServer.run(session -> assertTrue(new TrustMaterialResolver() + .resolveKey(session, DEFAULT_DISABLED_ALIAS, matchingRequest()).isEmpty())); + } + + @Test + public void oidcIdentityProviderResolvesConfiguredPublicKey() { + runOnServer.run(session -> { + TrustMaterialIdentityProvider provider = getTrustMaterialProvider(session.getContext().getRealm(), session, OIDC_ALIAS); + assertInstanceOf(OIDCIdentityProvider.class, provider); + + JWK jwk = provider.resolveKeys(TrustMaterialRequest.builder() + .kid(KEY_ID) + .algorithm(ALGORITHM) + .issuer(ISSUER) + .build()).findFirst().orElseThrow(); + + assertEquals(KEY_ID, jwk.getKeyId()); + assertEquals(ALGORITHM, jwk.getAlgorithm()); + }); + } + + @Test + public void oidcIdentityProviderResolvesConfiguredPublicKeyWithoutKid() { + runOnServer.run(session -> { + TrustMaterialIdentityProvider provider = getTrustMaterialProvider(session.getContext().getRealm(), session, OIDC_ALIAS); + + JWK jwk = provider.resolveKeys(TrustMaterialRequest.builder() + .algorithm(ALGORITHM) + .issuer(ISSUER) + .build()).findFirst().orElseThrow(); + + assertEquals(KEY_ID, jwk.getKeyId()); + assertEquals(ALGORITHM, jwk.getAlgorithm()); + }); + } + + @Test + public void oidcIdentityProviderRejectsIssuerMismatch() { + runOnServer.run(session -> { + TrustMaterialIdentityProvider provider = getTrustMaterialProvider(session.getContext().getRealm(), session, OIDC_ALIAS); + + assertTrue(provider.resolveKeys(TrustMaterialRequest.builder() + .kid(KEY_ID) + .algorithm(ALGORITHM) + .issuer("https://issuer.invalid") + .build()).findAny().isEmpty()); + }); + } + + @Test + public void oidcIdentityProviderDoesNotSplitConfiguredIssuer() { + runOnServer.run(session -> { + RealmModel realm = session.getContext().getRealm(); + IdentityProviderModel model = realm.getIdentityProviderByAlias(OIDC_ALIAS); + Map config = new HashMap<>(model.getConfig()); + config.put(IdentityProviderModel.ISSUER, ISSUER + ", https://issuer2.example.test"); + model.setConfig(config); + realm.updateIdentityProvider(model); + + TrustMaterialIdentityProvider provider = getTrustMaterialProvider(realm, session, OIDC_ALIAS); + + assertTrue(provider.resolveKeys(TrustMaterialRequest.builder() + .kid(KEY_ID) + .algorithm(ALGORITHM) + .issuer(ISSUER) + .build()).findAny().isEmpty()); + }); + } + + private static void configureTrustIdentityProvider(RealmModel realm, String alias, String providerId, boolean enabled, + Map config) { + IdentityProviderModel trustIdp = realm.getIdentityProviderByAlias(alias); + if (trustIdp == null) { + trustIdp = new IdentityProviderModel(); + trustIdp.setAlias(alias); + trustIdp.setProviderId(providerId); + trustIdp.setEnabled(enabled); + trustIdp.setConfig(config); + realm.addIdentityProvider(trustIdp); + } else { + trustIdp.setProviderId(providerId); + trustIdp.setEnabled(enabled); + trustIdp.setConfig(config); + realm.updateIdentityProvider(trustIdp); + } + } + + private static TrustMaterialIdentityProvider getTrustMaterialProvider(RealmModel realm, KeycloakSession session, String alias) { + IdentityProviderModel model = realm.getIdentityProviderByAlias(alias); + return IdentityBrokerService.getIdentityProvider(session, model, TrustMaterialIdentityProvider.class); + } + + private static TrustMaterialRequest matchingRequest() { + return TrustMaterialRequest.builder() + .kid(KEY_ID) + .algorithm(ALGORITHM) + .build(); + } + + private static KeyPair createRsaKeyPair() throws Exception { + KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); + generator.initialize(2048); + return generator.generateKeyPair(); + } + + public static class TrustMaterialServerConfig implements KeycloakServerConfig { + + @Override + public KeycloakServerConfigBuilder configure(KeycloakServerConfigBuilder config) { + return config.features(Profile.Feature.CLIENT_AUTH_ABCA); + } + } +} diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/abca/OIDCAttestationBasedClientAuthenticationTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/abca/OIDCAttestationBasedClientAuthenticationTest.java index b2598fe0cf1..6684386c127 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/abca/OIDCAttestationBasedClientAuthenticationTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/abca/OIDCAttestationBasedClientAuthenticationTest.java @@ -17,19 +17,22 @@ package org.keycloak.tests.oid4vc.abca; import java.security.PublicKey; +import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import org.keycloak.TokenVerifier; -import org.keycloak.authentication.authenticators.client.AttestationBasedClientAuthenticator; -import org.keycloak.authentication.authenticators.client.AttestationBasedClientAuthenticator.ABCAConfig; import org.keycloak.authentication.authenticators.client.AttestationBasedClientAuthenticator.ClientAttestationJwt; import org.keycloak.authentication.authenticators.client.AttestationBasedClientAuthenticator.ClientAttestationPoPJwt; +import org.keycloak.broker.trust.DefaultTrustIdentityProviderConfig; +import org.keycloak.broker.trust.DefaultTrustIdentityProviderFactory; import org.keycloak.common.VerificationException; import org.keycloak.crypto.KeyWrapper; +import org.keycloak.jose.jwk.JSONWebKeySet; import org.keycloak.jose.jwk.JWK; import org.keycloak.jose.jwk.JWKBuilder; -import org.keycloak.models.AuthenticatorConfigModel; +import org.keycloak.models.IdentityProviderModel; import org.keycloak.models.RealmModel; import org.keycloak.protocol.oid4vc.model.CredentialResponse; import org.keycloak.protocol.oid4vc.model.Proofs; @@ -47,7 +50,7 @@ import org.keycloak.util.JsonSerialization; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import static org.keycloak.authentication.authenticators.client.AttestationBasedClientAuthenticator.OAUTH_CLIENT_ATTESTATION_CONFIG_ATTESTER_JWKS; +import static org.keycloak.authentication.authenticators.client.AttestationBasedClientAuthenticator.OAUTH_CLIENT_ATTESTATION_CONFIG_TRUST_IDPS; import static org.keycloak.authentication.authenticators.client.AttestationBasedClientAuthenticator.OAUTH_CLIENT_ATTESTATION_HEADER; import static org.keycloak.authentication.authenticators.client.AttestationBasedClientAuthenticator.OAUTH_CLIENT_ATTESTATION_POP_HEADER; import static org.keycloak.protocol.oidc.OIDCLoginProtocol.ATTEST_JWT_CLIENT_AUTH; @@ -63,33 +66,63 @@ import static org.junit.jupiter.api.Assertions.assertTrue; @KeycloakIntegrationTest(config = OID4VCIssuerTestBase.VCTestServerWithABCAEnabled.class) public class OIDCAttestationBasedClientAuthenticationTest extends OID4VCIssuerTestBase { + private static final String ATTESTER_DEFAULT_TRUST_IDP_ALIAS = "abca-attester-default-trust"; + private static OIDCClientAttester attester; - private static ABCAConfig abcaConfig; + private static String attesterJwks; @TestSetup - public void configure() { + public void configure() throws Exception { var kw = createRsaKeyPair("openid-abca-attester-key"); JWK jwk = JWKBuilder.create() .kid(kw.getKid()) .algorithm(kw.getAlgorithm()) - .rsa(kw.getPublicKey(), kw.getCertificate()); - abcaConfig = new ABCAConfig().setKeys(List.of(jwk)); + .rsa(kw.getPublicKey()); + JSONWebKeySet jwks = new JSONWebKeySet(); + jwks.setKeys(new JWK[] { jwk }); + attesterJwks = JsonSerialization.writeValueAsString(jwks); attester = new OIDCMockClientAttester(kw); } @BeforeEach void beforeEach() { - String abcaConfigValue = JsonSerialization.valueAsString(abcaConfig); + String jwks = attesterJwks; runOnServer.run(session -> { RealmModel realm = session.getContext().getRealm(); - AuthenticatorConfigModel configModel = new AuthenticatorConfigModel(); - configModel.setAlias(AttestationBasedClientAuthenticator.PROVIDER_ID); - configModel.setConfig(Map.of(OAUTH_CLIENT_ATTESTATION_CONFIG_ATTESTER_JWKS, abcaConfigValue)); - realm.addAuthenticatorConfig(configModel); + + configureTrustIdentityProvider(realm, ATTESTER_DEFAULT_TRUST_IDP_ALIAS, + DefaultTrustIdentityProviderFactory.PROVIDER_ID, + Map.of(DefaultTrustIdentityProviderConfig.TRUSTED_JWKS, jwks)); }); + setClientTrustSource(ATTESTER_DEFAULT_TRUST_IDP_ALIAS); oauth.client(abcaClient.getClientId(), null); } + private static void configureTrustIdentityProvider(RealmModel realm, String alias, String providerId, Map config) { + IdentityProviderModel trustIdp = realm.getIdentityProviderByAlias(alias); + if (trustIdp == null) { + trustIdp = new IdentityProviderModel(); + trustIdp.setAlias(alias); + trustIdp.setProviderId(providerId); + trustIdp.setEnabled(true); + trustIdp.setConfig(config); + realm.addIdentityProvider(trustIdp); + } else { + trustIdp.setProviderId(providerId); + trustIdp.setEnabled(true); + trustIdp.setConfig(config); + realm.updateIdentityProvider(trustIdp); + } + } + + private void setClientTrustSource(String alias) { + Map attributes = new HashMap<>(Optional.ofNullable(abcaClient.getAttributes()).orElse(Map.of())); + attributes.put(OAUTH_CLIENT_ATTESTATION_CONFIG_TRUST_IDPS, alias); + abcaClient.setAttributes(attributes); + testRealm.admin().clients().get(abcaClient.getId()).update(abcaClient); + abcaClient = testRealm.admin().clients().get(abcaClient.getId()).toRepresentation(); + } + @Test public void testTokenEndpointAuthMethods() { OIDCConfigurationRepresentation oidcConfiguration = oauth.doWellKnownRequest(); @@ -137,6 +170,7 @@ public class OIDCAttestationBasedClientAuthenticationTest extends OID4VCIssuerTe @Test public void testClientAttestationHappyFlow() { + setClientTrustSource(ATTESTER_DEFAULT_TRUST_IDP_ALIAS); var ctx = new OID4VCTestContext(abcaClient, sdJwtTypeCredentialScope); ctx.putAttachment(CLIENT_ATTESTER_ATTACHMENT_KEY, attester);