Hostname SPI v2 (#26345)

* Hostname SPI v2

Closes: #26084

Signed-off-by: Václav Muzikář <vmuzikar@redhat.com>

* Fix HostnameV2DistTest#testServerFailsToStartWithoutHostnameSpecified

Signed-off-by: Václav Muzikář <vmuzikar@redhat.com>

* Address review comment

Signed-off-by: Václav Muzikář <vmuzikar@redhat.com>

* Partially revert the previous fix

Signed-off-by: Václav Muzikář <vmuzikar@redhat.com>

* Do not polish values

Signed-off-by: Václav Muzikář <vmuzikar@redhat.com>

* Remove filtering of denied categories

Signed-off-by: Václav Muzikář <vmuzikar@redhat.com>

---------

Signed-off-by: Václav Muzikář <vmuzikar@redhat.com>
This commit is contained in:
Václav Muzikář 2024-04-09 11:25:19 +02:00 committed by GitHub
parent 9651af4a1c
commit e4987f10f5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
58 changed files with 1429 additions and 1436 deletions

View file

@ -106,8 +106,8 @@ public class Profile {
CLIENT_TYPES("Client Types", Type.EXPERIMENTAL), CLIENT_TYPES("Client Types", Type.EXPERIMENTAL),
HOSTNAME_V1("Hostname Options V1", Type.DEFAULT), HOSTNAME_V1("Hostname Options V1", Type.DEPRECATED, 1),
//HOSTNAME_V2("Hostname Options V2", Type.DEFAULT, 2), HOSTNAME_V2("Hostname Options V2", Type.DEFAULT, 2),
PERSISTENT_USER_SESSIONS("Persistent online user sessions across restarts and upgrades", Type.EXPERIMENTAL), PERSISTENT_USER_SESSIONS("Persistent online user sessions across restarts and upgrades", Type.EXPERIMENTAL),
PERSISTENT_USER_SESSIONS_NO_CACHE("No caching for online user sessions when they are persisted", Type.EXPERIMENTAL), PERSISTENT_USER_SESSIONS_NO_CACHE("No caching for online user sessions when they are persisted", Type.EXPERIMENTAL),
@ -211,7 +211,7 @@ public class Profile {
} }
} }
private static final Set<String> ESSENTIAL_FEATURES = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(Feature.HOSTNAME_V1.getUnversionedKey()))); private static final Set<String> ESSENTIAL_FEATURES = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(Feature.HOSTNAME_V2.getUnversionedKey())));
private static final Logger logger = Logger.getLogger(Profile.class); private static final Logger logger = Logger.getLogger(Profile.class);
@ -293,9 +293,9 @@ public class Profile {
} }
} }
private static ProfileConfigResolver.FeatureConfig getFeatureConfig(String unversionedFeature, private static ProfileConfigResolver.FeatureConfig getFeatureConfig(String feature,
ProfileConfigResolver... resolvers) { ProfileConfigResolver... resolvers) {
ProfileConfigResolver.FeatureConfig configuration = Arrays.stream(resolvers).map(r -> r.getFeatureConfig(unversionedFeature)) ProfileConfigResolver.FeatureConfig configuration = Arrays.stream(resolvers).map(r -> r.getFeatureConfig(feature))
.filter(r -> !r.equals(ProfileConfigResolver.FeatureConfig.UNCONFIGURED)) .filter(r -> !r.equals(ProfileConfigResolver.FeatureConfig.UNCONFIGURED))
.findFirst() .findFirst()
.orElse(ProfileConfigResolver.FeatureConfig.UNCONFIGURED); .orElse(ProfileConfigResolver.FeatureConfig.UNCONFIGURED);

View file

@ -46,6 +46,6 @@ public class PropertiesProfileConfigResolver implements ProfileConfigResolver {
} }
public static String getPropertyKey(String feature) { public static String getPropertyKey(String feature) {
return "keycloak.profile.feature." + feature.replaceAll("-", "_"); return "keycloak.profile.feature." + feature.replaceAll("[-:]", "_");
} }
} }

View file

@ -5,7 +5,8 @@
<@tmpl.guide <@tmpl.guide
title="Configuring the hostname" title="Configuring the hostname"
summary="Learn how to configure the frontend and backchannel endpoints exposed by {project_name}." summary="Learn how to configure the frontend and backchannel endpoints exposed by {project_name}."
includedOptions="hostname hostname-* proxy"> includedOptions="hostname hostname-* proxy"
deniedCategories="hostname_v2">
== Server Endpoints == Server Endpoints

View file

@ -1,58 +1,58 @@
package org.keycloak.config; package org.keycloak.config;
public class HostnameOptions { public class HostnameV1Options {
public static final Option<String> HOSTNAME = new OptionBuilder<>("hostname", String.class) public static final Option<String> HOSTNAME = new OptionBuilder<>("hostname", String.class)
.category(OptionCategory.HOSTNAME) .category(OptionCategory.HOSTNAME_V1)
.description("Hostname for the Keycloak server.") .description("Hostname for the Keycloak server.")
.build(); .build();
public static final Option<String> HOSTNAME_URL = new OptionBuilder<>("hostname-url", String.class) public static final Option<String> HOSTNAME_URL = new OptionBuilder<>("hostname-url", String.class)
.category(OptionCategory.HOSTNAME) .category(OptionCategory.HOSTNAME_V1)
.description("Set the base URL for frontend URLs, including scheme, host, port and path.") .description("Set the base URL for frontend URLs, including scheme, host, port and path.")
.build(); .build();
public static final Option<String> HOSTNAME_ADMIN = new OptionBuilder<>("hostname-admin", String.class) public static final Option<String> HOSTNAME_ADMIN = new OptionBuilder<>("hostname-admin", String.class)
.category(OptionCategory.HOSTNAME) .category(OptionCategory.HOSTNAME_V1)
.description("The hostname for accessing the administration console. Use this option if you are exposing the administration console using a hostname other than the value set to the 'hostname' option.") .description("The hostname for accessing the administration console. Use this option if you are exposing the administration console using a hostname other than the value set to the 'hostname' option.")
.build(); .build();
public static final Option<String> HOSTNAME_ADMIN_URL = new OptionBuilder<>("hostname-admin-url", String.class) public static final Option<String> HOSTNAME_ADMIN_URL = new OptionBuilder<>("hostname-admin-url", String.class)
.category(OptionCategory.HOSTNAME) .category(OptionCategory.HOSTNAME_V1)
.description("Set the base URL for accessing the administration console, including scheme, host, port and path") .description("Set the base URL for accessing the administration console, including scheme, host, port and path")
.build(); .build();
public static final Option<Boolean> HOSTNAME_STRICT = new OptionBuilder<>("hostname-strict", Boolean.class) public static final Option<Boolean> HOSTNAME_STRICT = new OptionBuilder<>("hostname-strict", Boolean.class)
.category(OptionCategory.HOSTNAME) .category(OptionCategory.HOSTNAME_V1)
.description("Disables dynamically resolving the hostname from request headers. Should always be set to true in production, unless proxy verifies the Host header.") .description("Disables dynamically resolving the hostname from request headers. Should always be set to true in production, unless proxy verifies the Host header.")
.defaultValue(Boolean.TRUE) .defaultValue(Boolean.TRUE)
.build(); .build();
public static final Option<Boolean> HOSTNAME_STRICT_HTTPS = new OptionBuilder<>("hostname-strict-https", Boolean.class) public static final Option<Boolean> HOSTNAME_STRICT_HTTPS = new OptionBuilder<>("hostname-strict-https", Boolean.class)
.category(OptionCategory.HOSTNAME) .category(OptionCategory.HOSTNAME_V1)
.description("Forces frontend URLs to use the 'https' scheme. If set to false, the HTTP scheme is inferred from requests.") .description("Forces frontend URLs to use the 'https' scheme. If set to false, the HTTP scheme is inferred from requests.")
.hidden() .hidden()
.defaultValue(Boolean.TRUE) .defaultValue(Boolean.TRUE)
.build(); .build();
public static final Option<Boolean> HOSTNAME_STRICT_BACKCHANNEL = new OptionBuilder<>("hostname-strict-backchannel", Boolean.class) public static final Option<Boolean> HOSTNAME_STRICT_BACKCHANNEL = new OptionBuilder<>("hostname-strict-backchannel", Boolean.class)
.category(OptionCategory.HOSTNAME) .category(OptionCategory.HOSTNAME_V1)
.description("By default backchannel URLs are dynamically resolved from request headers to allow internal and external applications. If all applications use the public URL this option should be enabled.") .description("By default backchannel URLs are dynamically resolved from request headers to allow internal and external applications. If all applications use the public URL this option should be enabled.")
.build(); .build();
public static final Option<String> HOSTNAME_PATH = new OptionBuilder<>("hostname-path", String.class) public static final Option<String> HOSTNAME_PATH = new OptionBuilder<>("hostname-path", String.class)
.category(OptionCategory.HOSTNAME) .category(OptionCategory.HOSTNAME_V1)
.description("This should be set if proxy uses a different context-path for Keycloak.") .description("This should be set if proxy uses a different context-path for Keycloak.")
.build(); .build();
public static final Option<Integer> HOSTNAME_PORT = new OptionBuilder<>("hostname-port", Integer.class) public static final Option<Integer> HOSTNAME_PORT = new OptionBuilder<>("hostname-port", Integer.class)
.category(OptionCategory.HOSTNAME) .category(OptionCategory.HOSTNAME_V1)
.description("The port used by the proxy when exposing the hostname. Set this option if the proxy uses a port other than the default HTTP and HTTPS ports.") .description("The port used by the proxy when exposing the hostname. Set this option if the proxy uses a port other than the default HTTP and HTTPS ports.")
.defaultValue(-1) .defaultValue(-1)
.build(); .build();
public static final Option<Boolean> HOSTNAME_DEBUG = new OptionBuilder<>("hostname-debug", Boolean.class) public static final Option<Boolean> HOSTNAME_DEBUG = new OptionBuilder<>("hostname-debug", Boolean.class)
.category(OptionCategory.HOSTNAME) .category(OptionCategory.HOSTNAME_V1)
.description("Toggle the hostname debug page that is accessible at /realms/master/hostname-debug") .description("Toggle the hostname debug page that is accessible at /realms/master/hostname-debug")
.defaultValue(Boolean.FALSE) .defaultValue(Boolean.FALSE)
.build(); .build();

View file

@ -0,0 +1,33 @@
package org.keycloak.config;
public class HostnameV2Options {
public static final Option<String> HOSTNAME = new OptionBuilder<>("hostname", String.class)
.category(OptionCategory.HOSTNAME_V2)
.description("Address at which is the server exposed. Can be a full URL, or just a hostname. When only hostname is provided, scheme, port and context path are resolved from the request.")
.build();
public static final Option<String> HOSTNAME_ADMIN = new OptionBuilder<>("hostname-admin", String.class)
.category(OptionCategory.HOSTNAME_V2)
.description("Address for accessing the administration console. Use this option if you are exposing the administration console using a reverse proxy on a different address than specified in the 'hostname' option.")
.build();
public static final Option<Boolean> HOSTNAME_BACKCHANNEL_DYNAMIC = new OptionBuilder<>("hostname-backchannel-dynamic", Boolean.class)
.category(OptionCategory.HOSTNAME_V2)
.description("Enables dynamic resolving of backchannel URLs, including hostname, scheme, port and context path. Set to true if your application accesses Keycloak via a private network. If set to true, 'hostname' option needs to be specified as a full URL.")
.defaultValue(Boolean.FALSE)
.build();
public static final Option<Boolean> HOSTNAME_STRICT = new OptionBuilder<>("hostname-strict", Boolean.class)
.category(OptionCategory.HOSTNAME_V2)
.description("Disables dynamically resolving the hostname from request headers. Should always be set to true in production, unless your reverse proxy overwrites the Host header. If enabled, the 'hostname' option needs to be specified.")
.defaultValue(Boolean.TRUE)
.build();
public static final Option<Boolean> HOSTNAME_DEBUG = new OptionBuilder<>("hostname-debug", Boolean.class)
.category(OptionCategory.HOSTNAME_V2)
.description("Toggles the hostname debug page that is accessible at /realms/master/hostname-debug.")
.defaultValue(Boolean.FALSE)
.build();
}

View file

@ -124,6 +124,10 @@ public class OptionBuilder<T> {
public Option<T> build() { public Option<T> build() {
if (deprecatedMetadata == null && category.getSupportLevel() == ConfigSupportLevel.DEPRECATED) {
deprecated();
}
return new Option<T>(type, key, category, hidden, build, description, defaultValue, expectedValues, deprecatedMetadata); return new Option<T>(type, key, category, hidden, build, description, defaultValue, expectedValues, deprecatedMetadata);
} }

View file

@ -7,7 +7,8 @@ public enum OptionCategory {
DATABASE("Database", 20, ConfigSupportLevel.SUPPORTED), DATABASE("Database", 20, ConfigSupportLevel.SUPPORTED),
TRANSACTION("Transaction",30, ConfigSupportLevel.SUPPORTED), TRANSACTION("Transaction",30, ConfigSupportLevel.SUPPORTED),
FEATURE("Feature", 40, ConfigSupportLevel.SUPPORTED), FEATURE("Feature", 40, ConfigSupportLevel.SUPPORTED),
HOSTNAME("Hostname", 50, ConfigSupportLevel.SUPPORTED), HOSTNAME_V2("Hostname v2", 50, ConfigSupportLevel.SUPPORTED),
HOSTNAME_V1("Hostname v1", 51, ConfigSupportLevel.DEPRECATED),
HTTP("HTTP(S)", 60, ConfigSupportLevel.SUPPORTED), HTTP("HTTP(S)", 60, ConfigSupportLevel.SUPPORTED),
HEALTH("Health", 70, ConfigSupportLevel.SUPPORTED), HEALTH("Health", 70, ConfigSupportLevel.SUPPORTED),
MANAGEMENT("Management", 75, ConfigSupportLevel.SUPPORTED), MANAGEMENT("Management", 75, ConfigSupportLevel.SUPPORTED),
@ -48,7 +49,17 @@ public enum OptionCategory {
return switch (supportLevel) { return switch (supportLevel) {
case EXPERIMENTAL -> heading + " (Experimental)"; case EXPERIMENTAL -> heading + " (Experimental)";
case PREVIEW -> heading + " (Preview)"; case PREVIEW -> heading + " (Preview)";
case DEPRECATED -> heading + " (Deprecated)";
default -> heading; default -> heading;
}; };
} }
public static OptionCategory fromHeading(String heading) {
for (OptionCategory category : OptionCategory.values()) {
if (category.getHeading().equals(heading)) {
return category;
}
}
throw new RuntimeException("Could not find category with heading " + heading);
}
} }

View file

@ -115,9 +115,6 @@ import org.keycloak.theme.FolderThemeProviderFactory;
import org.keycloak.theme.JarThemeProviderFactory; import org.keycloak.theme.JarThemeProviderFactory;
import org.keycloak.theme.ThemeResourceSpi; import org.keycloak.theme.ThemeResourceSpi;
import org.keycloak.transaction.JBossJtaTransactionManagerLookup; import org.keycloak.transaction.JBossJtaTransactionManagerLookup;
import org.keycloak.url.DefaultHostnameProviderFactory;
import org.keycloak.url.FixedHostnameProviderFactory;
import org.keycloak.url.RequestHostnameProviderFactory;
import org.keycloak.userprofile.config.UPConfigUtils; import org.keycloak.userprofile.config.UPConfigUtils;
import org.keycloak.util.JsonSerialization; import org.keycloak.util.JsonSerialization;
import org.keycloak.vault.FilesKeystoreVaultProviderFactory; import org.keycloak.vault.FilesKeystoreVaultProviderFactory;
@ -178,9 +175,6 @@ class KeycloakProcessor {
DefaultLiquibaseConnectionProvider.class, DefaultLiquibaseConnectionProvider.class,
FolderThemeProviderFactory.class, FolderThemeProviderFactory.class,
LiquibaseJpaUpdaterProviderFactory.class, LiquibaseJpaUpdaterProviderFactory.class,
DefaultHostnameProviderFactory.class,
FixedHostnameProviderFactory.class,
RequestHostnameProviderFactory.class,
FilesKeystoreVaultProviderFactory.class, FilesKeystoreVaultProviderFactory.class,
FilesPlainTextVaultProviderFactory.class, FilesPlainTextVaultProviderFactory.class,
BlacklistPasswordPolicyProviderFactory.class, BlacklistPasswordPolicyProviderFactory.class,

View file

@ -17,7 +17,9 @@
package org.keycloak.quarkus.runtime.cli; package org.keycloak.quarkus.runtime.cli;
import static org.keycloak.quarkus.runtime.cli.OptionRenderer.undecorateDuplicitOptionName;
import static org.keycloak.quarkus.runtime.configuration.mappers.PropertyMappers.getMapper; import static org.keycloak.quarkus.runtime.configuration.mappers.PropertyMappers.getMapper;
import static org.keycloak.utils.StringUtil.removeSuffix;
import static picocli.CommandLine.Help.Column.Overflow.SPAN; import static picocli.CommandLine.Help.Column.Overflow.SPAN;
import static picocli.CommandLine.Help.Column.Overflow.WRAP; import static picocli.CommandLine.Help.Column.Overflow.WRAP;
@ -25,6 +27,8 @@ import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.Iterator; import java.util.Iterator;
import java.util.List; import java.util.List;
import org.keycloak.config.OptionCategory;
import org.keycloak.quarkus.runtime.configuration.mappers.PropertyMapper; import org.keycloak.quarkus.runtime.configuration.mappers.PropertyMapper;
import org.keycloak.quarkus.runtime.configuration.mappers.PropertyMappers; import org.keycloak.quarkus.runtime.configuration.mappers.PropertyMappers;
import org.keycloak.utils.StringUtil; import org.keycloak.utils.StringUtil;
@ -158,10 +162,16 @@ public final class Help extends CommandLine.Help {
return false; return false;
} }
PropertyMapper<?> mapper = getMapper(option.longestName()); String optionName = undecorateDuplicitOptionName(option.longestName());
OptionCategory category = null;
if (option.group() != null) {
category = OptionCategory.fromHeading(removeSuffix(option.group().heading(), ":"));
}
PropertyMapper<?> mapper = getMapper(optionName, category);
if (mapper == null) { if (mapper == null) {
final var disabledMapper = PropertyMappers.getDisabledMapper(option.longestName()); final var disabledMapper = PropertyMappers.getDisabledMapper(optionName);
final var isDisabledMapper = disabledMapper.isPresent(); final var isDisabledMapper = disabledMapper.isPresent();
// Show disabled mappers, which do not have a description when they're enabled // Show disabled mappers, which do not have a description when they're enabled

View file

@ -17,24 +17,22 @@
package org.keycloak.quarkus.runtime.cli; package org.keycloak.quarkus.runtime.cli;
import static org.keycloak.quarkus.runtime.cli.Picocli.NO_PARAM_LABEL;
import static picocli.CommandLine.Help.Ansi.OFF;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.StreamSupport;
import org.keycloak.utils.StringUtil; import org.keycloak.utils.StringUtil;
import picocli.CommandLine; import picocli.CommandLine;
import picocli.CommandLine.Help.Ansi.Text; import picocli.CommandLine.Help.Ansi.Text;
import picocli.CommandLine.Help.ColorScheme; import picocli.CommandLine.Help.ColorScheme;
import picocli.CommandLine.Help.IParamLabelRenderer; import picocli.CommandLine.Help.IParamLabelRenderer;
import picocli.CommandLine.Model.OptionSpec; import picocli.CommandLine.Model.OptionSpec;
import static org.keycloak.quarkus.runtime.cli.Picocli.NO_PARAM_LABEL;
import static org.keycloak.utils.StringUtil.removeSuffix;
import static picocli.CommandLine.Help.Ansi.OFF;
public class OptionRenderer implements CommandLine.Help.IOptionRenderer { public class OptionRenderer implements CommandLine.Help.IOptionRenderer {
private static final String OPTION_NAME_SEPARATOR = ", "; private static final String OPTION_NAME_SEPARATOR = ", ";
private static final Text EMPTY_TEXT = OFF.text(""); private static final Text EMPTY_TEXT = OFF.text("");
public static final String DUPLICIT_OPTION_SUFFIX = " "; // works good (not perfect) for alphabetical sorting with non-duplicit options
@Override @Override
public Text[][] render(OptionSpec option, IParamLabelRenderer paramLabelRenderer, ColorScheme scheme) { public Text[][] render(OptionSpec option, IParamLabelRenderer paramLabelRenderer, ColorScheme scheme) {
@ -65,7 +63,7 @@ public class OptionRenderer implements CommandLine.Help.IOptionRenderer {
} }
private Text createLongName(OptionSpec option, ColorScheme scheme) { private Text createLongName(OptionSpec option, ColorScheme scheme) {
Text name = scheme.optionText(option.longestName()); Text name = scheme.optionText(undecorateDuplicitOptionName(option.longestName()));
String paramLabel = formatParamLabel(option); String paramLabel = formatParamLabel(option);
if (StringUtil.isNotBlank(paramLabel) && !NO_PARAM_LABEL.equals(paramLabel) && !option.usageHelp() && !option.versionHelp()) { if (StringUtil.isNotBlank(paramLabel) && !NO_PARAM_LABEL.equals(paramLabel) && !option.usageHelp() && !option.versionHelp()) {
@ -84,4 +82,12 @@ public class OptionRenderer implements CommandLine.Help.IOptionRenderer {
return "<" + label + ">"; return "<" + label + ">";
} }
public static String decorateDuplicitOptionName(String name) {
return name + DUPLICIT_OPTION_SUFFIX;
}
public static String undecorateDuplicitOptionName(String name) {
return removeSuffix(name, DUPLICIT_OPTION_SUFFIX);
}
} }

View file

@ -23,6 +23,7 @@ import static java.util.stream.StreamSupport.stream;
import static org.keycloak.quarkus.runtime.Environment.isRebuild; import static org.keycloak.quarkus.runtime.Environment.isRebuild;
import static org.keycloak.quarkus.runtime.Environment.isRebuildCheck; import static org.keycloak.quarkus.runtime.Environment.isRebuildCheck;
import static org.keycloak.quarkus.runtime.Environment.isRebuilt; import static org.keycloak.quarkus.runtime.Environment.isRebuilt;
import static org.keycloak.quarkus.runtime.cli.OptionRenderer.decorateDuplicitOptionName;
import static org.keycloak.quarkus.runtime.cli.command.AbstractStartCommand.OPTIMIZED_BUILD_OPTION_LONG; import static org.keycloak.quarkus.runtime.cli.command.AbstractStartCommand.OPTIMIZED_BUILD_OPTION_LONG;
import static org.keycloak.quarkus.runtime.configuration.ConfigArgsConfigSource.parseConfigArgs; import static org.keycloak.quarkus.runtime.configuration.ConfigArgsConfigSource.parseConfigArgs;
import static org.keycloak.quarkus.runtime.configuration.Configuration.OPTION_PART_SEPARATOR; import static org.keycloak.quarkus.runtime.configuration.Configuration.OPTION_PART_SEPARATOR;
@ -99,7 +100,6 @@ public final class Picocli {
private static class IncludeOptions { private static class IncludeOptions {
boolean includeRuntime; boolean includeRuntime;
boolean includeBuildTime; boolean includeBuildTime;
boolean includeDisabled;
} }
private Picocli() { private Picocli() {
@ -378,7 +378,7 @@ public final class Picocli {
} }
if (!deprecatedInUse.isEmpty()) { if (!deprecatedInUse.isEmpty()) {
logger.warn("The following used options or option values are DEPRECATED and will be removed in a future release:\n" + String.join("\n", deprecatedInUse) + "\nConsult the Release Notes for details."); logger.warn("The following used options or option values are DEPRECATED and will be removed or their behaviour changed in a future release:\n" + String.join("\n", deprecatedInUse) + "\nConsult the Release Notes for details.");
} }
} finally { } finally {
DisabledMappersInterceptor.enable(disabledMappersInterceptorEnabled); DisabledMappersInterceptor.enable(disabledMappersInterceptorEnabled);
@ -618,7 +618,6 @@ public final class Picocli {
} }
result.includeRuntime = abstractCommand.includeRuntime(); result.includeRuntime = abstractCommand.includeRuntime();
result.includeBuildTime = abstractCommand.includeBuildTime(); result.includeBuildTime = abstractCommand.includeBuildTime();
result.includeDisabled = cliArgs.contains(HelpAllMixin.HELP_ALL_OPTION);
if (!result.includeBuildTime && !result.includeRuntime) { if (!result.includeBuildTime && !result.includeRuntime) {
return result; return result;
@ -660,34 +659,19 @@ public final class Picocli {
private static void addOptionsToCli(CommandLine commandLine, IncludeOptions includeOptions) { private static void addOptionsToCli(CommandLine commandLine, IncludeOptions includeOptions) {
final Map<OptionCategory, List<PropertyMapper<?>>> mappers = new EnumMap<>(OptionCategory.class); final Map<OptionCategory, List<PropertyMapper<?>>> mappers = new EnumMap<>(OptionCategory.class);
// Since we can't run sanitizeDisabledMappers sooner, PropertyMappers.getRuntime|BuildTimeMappers() at this point
// contain both enabled and disabled mappers. Actual filtering is done later (help command, validations etc.).
if (includeOptions.includeRuntime) { if (includeOptions.includeRuntime) {
mappers.putAll(PropertyMappers.getRuntimeMappers()); mappers.putAll(PropertyMappers.getRuntimeMappers());
if (includeOptions.includeDisabled) {
appendDisabledMappers(mappers, PropertyMappers.getDisabledRuntimeMappers());
}
} }
if (includeOptions.includeBuildTime) { if (includeOptions.includeBuildTime) {
combinePropertyMappers(mappers, PropertyMappers.getBuildTimeMappers()); combinePropertyMappers(mappers, PropertyMappers.getBuildTimeMappers());
if (includeOptions.includeDisabled) {
appendDisabledMappers(mappers, PropertyMappers.getDisabledBuildTimeMappers());
}
} }
addMappedOptionsToArgGroups(commandLine, mappers); addMappedOptionsToArgGroups(commandLine, mappers);
} }
private static void appendDisabledMappers(Map<OptionCategory, List<PropertyMapper<?>>> origMappers,
Map<String, PropertyMapper<?>> additionalMappers) {
for (var pm : additionalMappers.values()) {
final List<PropertyMapper<?>> result = origMappers.getOrDefault(pm.getCategory(), new ArrayList<>());
result.add(pm);
origMappers.put(pm.getCategory(), result);
}
}
private static <T extends Map<OptionCategory, List<PropertyMapper<?>>>> void combinePropertyMappers(T origMappers, T additionalMappers) { private static <T extends Map<OptionCategory, List<PropertyMapper<?>>>> void combinePropertyMappers(T origMappers, T additionalMappers) {
for (var entry : additionalMappers.entrySet()) { for (var entry : additionalMappers.entrySet()) {
final List<PropertyMapper<?>> result = origMappers.getOrDefault(entry.getKey(), new ArrayList<>()); final List<PropertyMapper<?>> result = origMappers.getOrDefault(entry.getKey(), new ArrayList<>());
@ -715,6 +699,14 @@ public final class Picocli {
for (PropertyMapper<?> mapper : mappersInCategory) { for (PropertyMapper<?> mapper : mappersInCategory) {
String name = mapper.getCliFormat(); String name = mapper.getCliFormat();
// Picocli doesn't allow to have multiple options with the same name. We need this in help-all which also prints
// currently disabled options which might have a duplicate among enabled options. This is to register the disabled
// options with a unique name in Picocli. To keep it simple, it adds just a suffix to the options, i.e. there cannot
// be more that 1 disabled option with a unique name.
if (cSpec.optionsMap().containsKey(name)) {
name = decorateDuplicitOptionName(name);
}
String description = mapper.getDescription(); String description = mapper.getDescription();
if (description == null || cSpec.optionsMap().containsKey(name) || name.endsWith(OPTION_PART_SEPARATOR) || alreadyPresentArgs.contains(name)) { if (description == null || cSpec.optionsMap().containsKey(name) || name.endsWith(OPTION_PART_SEPARATOR) || alreadyPresentArgs.contains(name)) {

View file

@ -2,6 +2,7 @@ package org.keycloak.quarkus.runtime.cli;
import org.keycloak.quarkus.runtime.cli.command.AbstractCommand; import org.keycloak.quarkus.runtime.cli.command.AbstractCommand;
import org.keycloak.quarkus.runtime.cli.command.Start; import org.keycloak.quarkus.runtime.cli.command.Start;
import org.keycloak.quarkus.runtime.configuration.KcUnmatchedArgumentException;
import org.keycloak.quarkus.runtime.configuration.mappers.PropertyMapper; import org.keycloak.quarkus.runtime.configuration.mappers.PropertyMapper;
import org.keycloak.quarkus.runtime.configuration.mappers.PropertyMappers; import org.keycloak.quarkus.runtime.configuration.mappers.PropertyMappers;
@ -72,6 +73,9 @@ public class ShortErrorMessageHandler implements IParameterExceptionHandler {
} }
writer.println(cmd.getColorScheme().errorText(errorMessage)); writer.println(cmd.getColorScheme().errorText(errorMessage));
if (!(ex instanceof KcUnmatchedArgumentException) && ex instanceof UnmatchedArgumentException) {
ex = new KcUnmatchedArgumentException((UnmatchedArgumentException) ex);
}
UnmatchedArgumentException.printSuggestions(ex, writer); UnmatchedArgumentException.printSuggestions(ex, writer);
CommandSpec spec = cmd.getCommandSpec(); CommandSpec spec = cmd.getCommandSpec();

View file

@ -52,7 +52,8 @@ public abstract class AbstractExportImportCommand extends AbstractStartCommand i
return super.getOptionCategories().stream().filter(optionCategory -> return super.getOptionCategories().stream().filter(optionCategory ->
optionCategory != OptionCategory.HTTP && optionCategory != OptionCategory.HTTP &&
optionCategory != OptionCategory.PROXY && optionCategory != OptionCategory.PROXY &&
optionCategory != OptionCategory.HOSTNAME && optionCategory != OptionCategory.HOSTNAME_V1 &&
optionCategory != OptionCategory.HOSTNAME_V2 &&
optionCategory != OptionCategory.METRICS && optionCategory != OptionCategory.METRICS &&
optionCategory != OptionCategory.VAULT && optionCategory != OptionCategory.VAULT &&
optionCategory != OptionCategory.SECURITY && optionCategory != OptionCategory.SECURITY &&

View file

@ -22,6 +22,8 @@ import picocli.CommandLine;
import java.util.List; import java.util.List;
import static org.keycloak.quarkus.runtime.cli.OptionRenderer.DUPLICIT_OPTION_SUFFIX;
/** /**
* Custom CommandLine.UnmatchedArgumentException with amended suggestions * Custom CommandLine.UnmatchedArgumentException with amended suggestions
*/ */
@ -31,9 +33,13 @@ public class KcUnmatchedArgumentException extends CommandLine.UnmatchedArgumentE
super(commandLine, args); super(commandLine, args);
} }
public KcUnmatchedArgumentException(CommandLine.UnmatchedArgumentException ex) {
super(ex.getCommandLine(), ex.getUnmatched());
}
@Override @Override
public List<String> getSuggestions() { public List<String> getSuggestions() {
// filter out disabled mappers // filter out disabled mappers
return super.getSuggestions().stream().filter(f -> !PropertyMappers.isDisabledMapper(f)).toList(); return super.getSuggestions().stream().filter(f -> !PropertyMappers.isDisabledMapper(f) && !f.endsWith(DUPLICIT_OPTION_SUFFIX)).toList();
} }
} }

View file

@ -1,52 +0,0 @@
package org.keycloak.quarkus.runtime.configuration.mappers;
import org.keycloak.config.HostnameOptions;
import static org.keycloak.quarkus.runtime.configuration.mappers.PropertyMapper.fromOption;
final class HostnamePropertyMappers {
private HostnamePropertyMappers(){}
public static PropertyMapper<?>[] getHostnamePropertyMappers() {
return new PropertyMapper[] {
fromOption(HostnameOptions.HOSTNAME)
.to("kc.spi-hostname-default-hostname")
.paramLabel("hostname")
.build(),
fromOption(HostnameOptions.HOSTNAME_URL)
.to("kc.spi-hostname-default-hostname-url")
.paramLabel("url")
.build(),
fromOption(HostnameOptions.HOSTNAME_ADMIN)
.to("kc.spi-hostname-default-admin")
.paramLabel("hostname")
.build(),
fromOption(HostnameOptions.HOSTNAME_ADMIN_URL)
.to("kc.spi-hostname-default-admin-url")
.paramLabel("url")
.build(),
fromOption(HostnameOptions.HOSTNAME_STRICT)
.to("kc.spi-hostname-default-strict")
.build(),
fromOption(HostnameOptions.HOSTNAME_STRICT_HTTPS)
.to("kc.spi-hostname-default-strict-https")
.build(),
fromOption(HostnameOptions.HOSTNAME_STRICT_BACKCHANNEL)
.to("kc.spi-hostname-default-strict-backchannel")
.build(),
fromOption(HostnameOptions.HOSTNAME_PATH)
.to("kc.spi-hostname-default-path")
.paramLabel("path")
.build(),
fromOption(HostnameOptions.HOSTNAME_PORT)
.to("kc.spi-hostname-default-hostname-port")
.paramLabel("port")
.build(),
fromOption(HostnameOptions.HOSTNAME_DEBUG)
.to("kc.spi-hostname-default-hostname-debug")
.build()
};
}
}

View file

@ -0,0 +1,48 @@
package org.keycloak.quarkus.runtime.configuration.mappers;
import org.keycloak.common.Profile;
import org.keycloak.config.HostnameV1Options;
import java.util.List;
import java.util.stream.Stream;
import static org.keycloak.quarkus.runtime.configuration.mappers.PropertyMapper.fromOption;
final class HostnameV1PropertyMappers {
private HostnameV1PropertyMappers(){}
public static PropertyMapper<?>[] getHostnamePropertyMappers() {
return Stream.of(
fromOption(HostnameV1Options.HOSTNAME)
.to("kc.spi-hostname-default-hostname")
.paramLabel("hostname"),
fromOption(HostnameV1Options.HOSTNAME_URL)
.to("kc.spi-hostname-default-hostname-url")
.paramLabel("url"),
fromOption(HostnameV1Options.HOSTNAME_ADMIN)
.to("kc.spi-hostname-default-admin")
.paramLabel("hostname"),
fromOption(HostnameV1Options.HOSTNAME_ADMIN_URL)
.to("kc.spi-hostname-default-admin-url")
.paramLabel("url"),
fromOption(HostnameV1Options.HOSTNAME_STRICT)
.to("kc.spi-hostname-default-strict"),
fromOption(HostnameV1Options.HOSTNAME_STRICT_HTTPS)
.to("kc.spi-hostname-default-strict-https"),
fromOption(HostnameV1Options.HOSTNAME_STRICT_BACKCHANNEL)
.to("kc.spi-hostname-default-strict-backchannel"),
fromOption(HostnameV1Options.HOSTNAME_PATH)
.to("kc.spi-hostname-default-path")
.paramLabel("path"),
fromOption(HostnameV1Options.HOSTNAME_PORT)
.to("kc.spi-hostname-default-hostname-port")
.paramLabel("port"),
fromOption(HostnameV1Options.HOSTNAME_DEBUG)
.to("kc.spi-hostname-default-hostname-debug")
)
.map(b -> b.isEnabled(() -> Profile.isFeatureEnabled(Profile.Feature.HOSTNAME_V1), "hostname:v1 feature is enabled").build())
.toArray(s -> new PropertyMapper<?>[s]);
}
}

View file

@ -0,0 +1,32 @@
package org.keycloak.quarkus.runtime.configuration.mappers;
import org.keycloak.common.Profile;
import org.keycloak.config.HostnameV2Options;
import java.util.stream.Stream;
import static org.keycloak.quarkus.runtime.configuration.mappers.PropertyMapper.fromOption;
final class HostnameV2PropertyMappers {
private HostnameV2PropertyMappers(){}
public static PropertyMapper<?>[] getHostnamePropertyMappers() {
return Stream.of(
fromOption(HostnameV2Options.HOSTNAME)
.to("kc.spi-hostname-v2-hostname")
.paramLabel("hostname|URL"),
fromOption(HostnameV2Options.HOSTNAME_ADMIN)
.to("kc.spi-hostname-v2-hostname-admin")
.paramLabel("URL"),
fromOption(HostnameV2Options.HOSTNAME_BACKCHANNEL_DYNAMIC)
.to("kc.spi-hostname-v2-hostname-backchannel-dynamic"),
fromOption(HostnameV2Options.HOSTNAME_STRICT)
.to("kc.spi-hostname-v2-hostname-strict"),
fromOption(HostnameV2Options.HOSTNAME_DEBUG)
)
.map(b -> b.isEnabled(() -> Profile.isFeatureEnabled(Profile.Feature.HOSTNAME_V2), "hostname:v2 feature is enabled").build())
.toArray(s -> new PropertyMapper<?>[s]);
}
}

View file

@ -18,7 +18,6 @@ import org.keycloak.quarkus.runtime.configuration.DisabledMappersInterceptor;
import org.keycloak.quarkus.runtime.configuration.PersistedConfigSource; import org.keycloak.quarkus.runtime.configuration.PersistedConfigSource;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection; import java.util.Collection;
import java.util.Collections; import java.util.Collections;
import java.util.EnumMap; import java.util.EnumMap;
@ -29,7 +28,6 @@ import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.function.BiConsumer; import java.util.function.BiConsumer;
import java.util.Set; import java.util.Set;
import java.util.function.BooleanSupplier;
import java.util.function.Predicate; import java.util.function.Predicate;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@ -48,7 +46,8 @@ public final class PropertyMappers {
static { static {
MAPPERS.addAll(CachingPropertyMappers.getClusteringPropertyMappers()); MAPPERS.addAll(CachingPropertyMappers.getClusteringPropertyMappers());
MAPPERS.addAll(DatabasePropertyMappers.getDatabasePropertyMappers()); MAPPERS.addAll(DatabasePropertyMappers.getDatabasePropertyMappers());
MAPPERS.addAll(HostnamePropertyMappers.getHostnamePropertyMappers()); MAPPERS.addAll(HostnameV2PropertyMappers.getHostnamePropertyMappers());
MAPPERS.addAll(HostnameV1PropertyMappers.getHostnamePropertyMappers());
MAPPERS.addAll(HttpPropertyMappers.getHttpPropertyMappers()); MAPPERS.addAll(HttpPropertyMappers.getHttpPropertyMappers());
MAPPERS.addAll(HealthPropertyMappers.getHealthPropertyMappers()); MAPPERS.addAll(HealthPropertyMappers.getHealthPropertyMappers());
MAPPERS.addAll(ConfigKeystorePropertyMappers.getConfigKeystorePropertyMappers()); MAPPERS.addAll(ConfigKeystorePropertyMappers.getConfigKeystorePropertyMappers());
@ -145,33 +144,33 @@ public final class PropertyMappers {
return property; return property;
} }
private static PropertyMapper<?> getMapperOrDefault(String property, PropertyMapper<?> defaultMapper) { private static PropertyMapper<?> getMapperOrDefault(String property, PropertyMapper<?> defaultMapper, OptionCategory category) {
final var mappers = MAPPERS.getOrDefault(property, Collections.emptyList()); property = removeProfilePrefixIfNeeded(property);
final var mappers = new ArrayList<>(MAPPERS.getOrDefault(property, Collections.emptyList()));
if (category != null) {
mappers.removeIf(m -> !m.getCategory().equals(category));
}
return switch (mappers.size()) { return switch (mappers.size()) {
case 0 -> defaultMapper; case 0 -> defaultMapper;
case 1 -> mappers.get(0); case 1 -> mappers.get(0);
default -> {
var allowedMappers = filterDeniedCategories(mappers);
yield switch (allowedMappers.size()) {
case 0 -> defaultMapper;
case 1 -> allowedMappers.iterator().next();
default -> { default -> {
log.debugf("Duplicated mappers for key '%s'. Used the first found.", property); log.debugf("Duplicated mappers for key '%s'. Used the first found.", property);
yield allowedMappers.iterator().next(); yield mappers.get(0);
} }
}; };
} }
};
private static PropertyMapper<?> getMapperOrDefault(String property, PropertyMapper<?> defaultMapper) {
return getMapperOrDefault(property, defaultMapper, null);
}
public static PropertyMapper<?> getMapper(String property, OptionCategory category) {
return getMapperOrDefault(property, null, category);
} }
public static PropertyMapper<?> getMapper(String property) { public static PropertyMapper<?> getMapper(String property) {
return getMapperOrDefault(polishProperty(property), null); return getMapper(property, null);
}
public static List<PropertyMapper<?>> getMappers(String property) {
return MAPPERS.get(polishProperty(property));
} }
public static Set<PropertyMapper<?>> getMappers() { public static Set<PropertyMapper<?>> getMappers() {
@ -179,7 +178,8 @@ public final class PropertyMappers {
} }
public static boolean isSupported(PropertyMapper<?> mapper) { public static boolean isSupported(PropertyMapper<?> mapper) {
return mapper.getCategory().getSupportLevel().equals(ConfigSupportLevel.SUPPORTED); ConfigSupportLevel supportLevel = mapper.getCategory().getSupportLevel();
return supportLevel.equals(ConfigSupportLevel.SUPPORTED) || supportLevel.equals(ConfigSupportLevel.DEPRECATED);
} }
public static Optional<PropertyMapper<?>> getDisabledMapper(String property) { public static Optional<PropertyMapper<?>> getDisabledMapper(String property) {
@ -201,10 +201,6 @@ public final class PropertyMappers {
return isDisabledMapper.test(property); return isDisabledMapper.test(property);
} }
private static String polishProperty(String property) {
return property.startsWith("%") ? property.substring(property.indexOf('.') + 1) : property;
}
private static Set<PropertyMapper<?>> filterDeniedCategories(List<PropertyMapper<?>> mappers) { private static Set<PropertyMapper<?>> filterDeniedCategories(List<PropertyMapper<?>> mappers) {
final var allowedCategories = Environment.getParsedCommand() final var allowedCategories = Environment.getParsedCommand()
.map(AbstractCommand::getOptionCategories) .map(AbstractCommand::getOptionCategories)
@ -222,15 +218,6 @@ public final class PropertyMappers {
private final Map<String, PropertyMapper<?>> disabledBuildTimeMappers = new HashMap<>(); private final Map<String, PropertyMapper<?>> disabledBuildTimeMappers = new HashMap<>();
private final Map<String, PropertyMapper<?>> disabledRuntimeMappers = new HashMap<>(); private final Map<String, PropertyMapper<?>> disabledRuntimeMappers = new HashMap<>();
public void addAll(PropertyMapper<?>[] mappers, BooleanSupplier isEnabled, String enabledWhen) {
Arrays.stream(mappers).forEach(mapper -> {
mapper.setEnabled(isEnabled);
mapper.setEnabledWhen(enabledWhen);
});
addAll(mappers);
}
public void addAll(PropertyMapper<?>[] mappers) { public void addAll(PropertyMapper<?>[] mappers) {
for (PropertyMapper<?> mapper : mappers) { for (PropertyMapper<?> mapper : mappers) {
addMapper(mapper); addMapper(mapper);

View file

@ -41,7 +41,7 @@ import org.keycloak.common.Profile;
import org.keycloak.common.Profile.Feature; import org.keycloak.common.Profile.Feature;
import org.keycloak.common.enums.SslRequired; import org.keycloak.common.enums.SslRequired;
import org.keycloak.common.util.Resteasy; import org.keycloak.common.util.Resteasy;
import org.keycloak.config.HostnameOptions; import org.keycloak.config.HostnameV1Options;
import org.keycloak.config.ProxyOptions; import org.keycloak.config.ProxyOptions;
import org.keycloak.config.ProxyOptions.Mode; import org.keycloak.config.ProxyOptions.Mode;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
@ -272,7 +272,7 @@ public final class DefaultHostnameProvider implements HostnameProvider, Hostname
} }
if (frontEndHostName != null && frontEndBaseUri != null) { if (frontEndHostName != null && frontEndBaseUri != null) {
throw new RuntimeException("You can not set both '" + HostnameOptions.HOSTNAME.getKey() + "' and '" + HostnameOptions.HOSTNAME_URL.getKey() + "' options"); throw new RuntimeException("You can not set both '" + HostnameV1Options.HOSTNAME.getKey() + "' and '" + HostnameV1Options.HOSTNAME_URL.getKey() + "' options");
} }
if (config.getBoolean("strict", false) && (frontEndHostName == null && frontEndBaseUri == null)) { if (config.getBoolean("strict", false) && (frontEndHostName == null && frontEndBaseUri == null)) {
@ -325,7 +325,7 @@ public final class DefaultHostnameProvider implements HostnameProvider, Hostname
} }
if (adminHostName != null && adminBaseUri != null) { if (adminHostName != null && adminBaseUri != null) {
throw new RuntimeException("You can not set both '" + HostnameOptions.HOSTNAME_ADMIN.getKey() + "' and '" + HostnameOptions.HOSTNAME_ADMIN_URL.getKey() + "' options"); throw new RuntimeException("You can not set both '" + HostnameV1Options.HOSTNAME_ADMIN.getKey() + "' and '" + HostnameV1Options.HOSTNAME_ADMIN_URL.getKey() + "' options");
} }
if (adminBaseUri != null) { if (adminBaseUri != null) {

View file

@ -44,4 +44,16 @@ public class ConstantsDebugHostname {
"https-port" "https-port"
}; };
public static final String[] RELEVANT_OPTIONS_V2 = {
"hostname",
"hostname-admin",
"hostname-backchannel-dynamic",
"hostname-strict",
"proxy-headers",
"http-enabled",
"http-relative-path",
"http-port",
"https-port"
};
} }

View file

@ -17,7 +17,17 @@
package org.keycloak.quarkus.runtime.services.resources; package org.keycloak.quarkus.runtime.services.resources;
import io.quarkus.resteasy.reactive.server.EndpointDisabled; import io.quarkus.resteasy.reactive.server.EndpointDisabled;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.NotFoundException; import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.Provider;
import org.keycloak.common.Profile;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel; import org.keycloak.models.RealmModel;
import org.keycloak.quarkus.runtime.Environment; import org.keycloak.quarkus.runtime.Environment;
@ -29,16 +39,6 @@ import org.keycloak.theme.Theme;
import org.keycloak.theme.freemarker.FreeMarkerProvider; import org.keycloak.theme.freemarker.FreeMarkerProvider;
import org.keycloak.urls.UrlType; import org.keycloak.urls.UrlType;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.Provider;
import java.io.IOException; import java.io.IOException;
import java.net.URI; import java.net.URI;
import java.util.HashMap; import java.util.HashMap;
@ -62,7 +62,8 @@ public class DebugHostnameSettingsResource {
public DebugHostnameSettingsResource() { public DebugHostnameSettingsResource() {
this.allConfigPropertiesMap = new LinkedHashMap<>(); this.allConfigPropertiesMap = new LinkedHashMap<>();
for (String key : ConstantsDebugHostname.RELEVANT_OPTIONS) { String[] relevantOptions = Profile.isFeatureEnabled(Profile.Feature.HOSTNAME_V2) ? ConstantsDebugHostname.RELEVANT_OPTIONS_V2 : ConstantsDebugHostname.RELEVANT_OPTIONS;
for (String key : relevantOptions) {
addOption(key); addOption(key);
} }
@ -95,6 +96,7 @@ public class DebugHostnameSettingsResource {
attributes.put("realm", realmModel.getName()); attributes.put("realm", realmModel.getName());
attributes.put("realmUrl", realmModel.getAttribute("frontendUrl")); attributes.put("realmUrl", realmModel.getAttribute("frontendUrl"));
attributes.put("implVersion", Profile.isFeatureEnabled(Profile.Feature.HOSTNAME_V2) ? "V2" : "V1");
attributes.put("frontendTestUrl", frontendTestUrl); attributes.put("frontendTestUrl", frontendTestUrl);
attributes.put("backendTestUrl", backendTestUrl); attributes.put("backendTestUrl", backendTestUrl);

View file

@ -70,6 +70,10 @@
<td>${realmUrl}</td> <td>${realmUrl}</td>
</tr> </tr>
</#if> </#if>
<tr>
<td>Hostname SPI implementation</td>
<td>${implVersion}</td>
</tr>
<tr> <tr>
<th>Configuration property</th> <th>Configuration property</th>

View file

@ -38,10 +38,10 @@ import java.util.function.Consumer;
import static io.restassured.RestAssured.when; import static io.restassured.RestAssured.when;
import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.MatcherAssert.assertThat;
@DistributionTest(keepAlive = true, enableTls = true, defaultOptions = { "--http-enabled=true" }) @DistributionTest(keepAlive = true, enableTls = true, defaultOptions = { "--http-enabled=true", "--features=hostname:v1" })
@WithEnvVars({"KEYCLOAK_ADMIN", "admin123", "KEYCLOAK_ADMIN_PASSWORD", "admin123"}) @WithEnvVars({"KEYCLOAK_ADMIN", "admin123", "KEYCLOAK_ADMIN_PASSWORD", "admin123"})
@RawDistOnly(reason = "Containers are immutable") @RawDistOnly(reason = "Containers are immutable")
public class HostnameDistTest { public class HostnameV1DistTest {
@BeforeAll @BeforeAll
public static void onBeforeAll() { public static void onBeforeAll() {

View file

@ -0,0 +1,44 @@
/*
* Copyright 2021 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.it.cli.dist;
import io.quarkus.test.junit.main.Launch;
import io.quarkus.test.junit.main.LaunchResult;
import org.junit.jupiter.api.Test;
import org.keycloak.it.junit5.extension.CLIResult;
import org.keycloak.it.junit5.extension.DistributionTest;
import org.keycloak.it.junit5.extension.RawDistOnly;
import static org.hamcrest.CoreMatchers.containsString;
import static org.hamcrest.MatcherAssert.assertThat;
@DistributionTest
@RawDistOnly(reason = "Containers are immutable")
public class HostnameV2DistTest {
@Test
@Launch({"start", "--http-enabled=true"})
public void testServerFailsToStartWithoutHostnameSpecified(LaunchResult result) {
assertThat(result.getErrorOutput(), containsString("ERROR: hostname is not configured; either configure hostname, or set hostname-strict to false"));
}
@Test
@Launch({"start-dev"})
public void testServerStartsDevtWithoutHostnameSpecified(LaunchResult result) {
((CLIResult) result).assertStartedDevMode();
}
}

View file

@ -34,10 +34,10 @@ import static io.restassured.RestAssured.given;
import static io.restassured.RestAssured.when; import static io.restassured.RestAssured.when;
import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.containsString;
@DistributionTest(keepAlive = true, enableTls = true) @DistributionTest(keepAlive = true, enableTls = true, defaultOptions = "--features=hostname:v1")
@WithEnvVars({"KEYCLOAK_ADMIN", "admin123", "KEYCLOAK_ADMIN_PASSWORD", "admin123"}) @WithEnvVars({"KEYCLOAK_ADMIN", "admin123", "KEYCLOAK_ADMIN_PASSWORD", "admin123"})
@RawDistOnly(reason = "Containers are immutable") @RawDistOnly(reason = "Containers are immutable")
public class ProxyDistTest { public class ProxyHostnameV1DistTest {
@BeforeAll @BeforeAll
public static void onBeforeAll() { public static void onBeforeAll() {

View file

@ -0,0 +1,138 @@
/*
* Copyright 2021 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.it.cli.dist;
import io.quarkus.test.junit.main.Launch;
import io.restassured.RestAssured;
import io.restassured.config.RedirectConfig;
import io.restassured.config.RestAssuredConfig;
import org.apache.http.HttpHeaders;
import org.junit.Assert;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.keycloak.it.junit5.extension.DistributionTest;
import org.keycloak.it.junit5.extension.RawDistOnly;
import org.keycloak.it.junit5.extension.WithEnvVars;
import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation;
import static io.restassured.RestAssured.given;
import static io.restassured.RestAssured.when;
import static org.hamcrest.Matchers.containsString;
@DistributionTest(keepAlive = true, enableTls = true)
@WithEnvVars({"KEYCLOAK_ADMIN", "admin123", "KEYCLOAK_ADMIN_PASSWORD", "admin123"})
@RawDistOnly(reason = "Containers are immutable")
public class ProxyHostnameV2DistTest {
@BeforeAll
public static void onBeforeAll() {
RestAssured.useRelaxedHTTPSValidation();
RestAssuredConfig config = RestAssured.config;
RestAssured.config = config.redirect(RedirectConfig.redirectConfig().followRedirects(false));
}
@Test
@Launch({ "start-dev", "--hostname-strict=false" })
public void testSchemeAndPortFromRequestWhenNoProxySet() {
assertFrontEndUrl("http://mykeycloak.org:8080", "http://mykeycloak.org:8080/");
assertFrontEndUrl("http://localhost:8080", "http://localhost:8080/");
assertFrontEndUrl("https://localhost:8443", "https://localhost:8443/");
assertForwardedHeaderIsIgnored();
assertXForwardedHeadersAreIgnored();
}
@Test
@Launch({ "start-dev", "--hostname-strict=false", "--proxy-headers=forwarded" })
public void testForwardedProxyHeaders() {
assertForwardedHeader();
assertXForwardedHeadersAreIgnored();
}
@Test
@Launch({ "start-dev", "--hostname-strict=false", "--proxy-headers=xforwarded" })
public void testXForwardedProxyHeaders() {
assertForwardedHeaderIsIgnored();
assertXForwardedHeaders();
}
@Test
@Launch({ "start-dev", "--hostname-strict=false", "--proxy-headers=xforwarded", "--proxy=reencrypt" })
public void testProxyHeadersTakePrecedenceOverProxyReencryptOption() {
assertForwardedHeaderIsIgnored();
assertXForwardedHeaders();
}
@Test
@Launch({ "start-dev", "--hostname-strict=false", "--proxy-headers=xforwarded", "--proxy=none" })
public void testProxyHeadersTakePrecedenceOverProxyNoneOption() {
assertForwardedHeaderIsIgnored();
assertXForwardedHeaders();
}
@Test
@Launch({ "start-dev", "--hostname=mykeycloak.org", "--proxy-headers=forwarded", "--proxy=none" })
public void testExplicitlySetHostnameTakesPrecedenceOverProxyHeaders() {
assertForwardedHeader("https://mykeycloak.org:1234/admin");
}
@Test
@Launch({ "start-dev", "--hostname=http://mykeycloak.org:8080", "--proxy-headers=forwarded", "--proxy=none" })
public void testExplicitlySetHostnameUrlTakesPrecedenceOverProxyHeaders() {
assertForwardedHeader("http://mykeycloak.org:8080/admin");
}
private void assertForwardedHeader() {
assertForwardedHeader("https://test:1234/admin");
}
private void assertForwardedHeader(String expectedUrl) {
given()
.header("Forwarded", "for=12.34.56.78;host=test:1234;proto=https, for=23.45.67.89")
.when().get("http://mykeycloak.org:8080")
.then().header(HttpHeaders.LOCATION, containsString(expectedUrl));
}
private void assertForwardedHeaderIsIgnored() {
given().header("Forwarded", "for=12.34.56.78;host=test:1234;proto=https, for=23.45.67.89").when().get("http://localhost:8080").then().header(HttpHeaders.LOCATION, containsString("http://localhost:8080"));
}
private void assertXForwardedHeaders() {
given().header("X-Forwarded-Host", "test").when().get("http://mykeycloak.org:8080").then().header(HttpHeaders.LOCATION, containsString("http://test:8080/admin"));
given().header("X-Forwarded-Host", "test").when().get("http://localhost:8080").then().header(HttpHeaders.LOCATION, containsString("http://test:8080/admin"));
given().header("X-Forwarded-Host", "test").when().get("https://localhost:8443").then().header(HttpHeaders.LOCATION, containsString("https://test:8443/admin"));
given().header("X-Forwarded-Proto", "https").when().get("http://localhost:8080").then().header(HttpHeaders.LOCATION, containsString("https://localhost/admin"));
given().header("X-Forwarded-Proto", "https").header("X-Forwarded-Port", "8443").when().get("http://localhost:8080").then().header(HttpHeaders.LOCATION, containsString("https://localhost:8443/admin"));
}
private void assertXForwardedHeadersAreIgnored() {
given().header("X-Forwarded-Host", "test").when().get("http://mykeycloak.org:8080").then().header(HttpHeaders.LOCATION, containsString("http://mykeycloak.org:8080/admin"));
given().header("X-Forwarded-Host", "test").when().get("http://localhost:8080").then().header(HttpHeaders.LOCATION, containsString("http://localhost:8080/admin"));
given().header("X-Forwarded-Host", "test").when().get("https://localhost:8443").then().header(HttpHeaders.LOCATION, containsString("https://localhost:8443/admin"));
given().header("X-Forwarded-Proto", "https").when().get("http://localhost:8080").then().header(HttpHeaders.LOCATION, containsString("http://localhost:8080/admin"));
given().header("X-Forwarded-Proto", "https").header("X-Forwarded-Port", "8443").when().get("http://localhost:8080").then().header(HttpHeaders.LOCATION, containsString("http://localhost:8080/admin"));
}
private OIDCConfigurationRepresentation getServerMetadata(String baseUrl) {
return when().get(baseUrl + "/realms/master/.well-known/openid-configuration").as(OIDCConfigurationRepresentation.class);
}
private void assertFrontEndUrl(String requestBaseUrl, String expectedBaseUrl) {
Assert.assertEquals(expectedBaseUrl + "realms/master/protocol/openid-connect/auth", getServerMetadata(requestBaseUrl)
.getAuthorizationEndpoint());
}
}

View file

@ -84,7 +84,7 @@ public class StartCommandDistTest {
@Test @Test
@Launch({ "start", "--http-enabled=true" }) @Launch({ "start", "--http-enabled=true" })
void failNoHostnameNotSet(LaunchResult result) { void failNoHostnameNotSet(LaunchResult result) {
assertTrue(result.getErrorOutput().contains("ERROR: Strict hostname resolution configured but no hostname setting provided"), assertTrue(result.getErrorOutput().contains("ERROR: hostname is not configured; either configure hostname, or set hostname-strict to false"),
() -> "The Output:\n" + result.getOutput() + "doesn't contains the expected string."); () -> "The Output:\n" + result.getOutput() + "doesn't contains the expected string.");
} }

View file

@ -120,34 +120,32 @@ Feature:
--features-disabled <feature> --features-disabled <feature>
Disables a set of one or more features. Possible values are: <...>. Disables a set of one or more features. Possible values are: <...>.
Hostname: Hostname v2:
--hostname <hostname> --hostname <hostname|URL>
Hostname for the Keycloak server. Address at which is the server exposed. Can be a full URL, or just a hostname.
--hostname-admin <hostname> When only hostname is provided, scheme, port and context path are resolved
The hostname for accessing the administration console. Use this option if you from the request. Available only when hostname:v2 feature is enabled.
are exposing the administration console using a hostname other than the --hostname-admin <URL>
value set to the 'hostname' option. Address for accessing the administration console. Use this option if you are
--hostname-admin-url <url> exposing the administration console using a reverse proxy on a different
Set the base URL for accessing the administration console, including scheme, address than specified in the 'hostname' option. Available only when
host, port and path hostname:v2 feature is enabled.
--hostname-backchannel-dynamic <true|false>
Enables dynamic resolving of backchannel URLs, including hostname, scheme,
port and context path. Set to true if your application accesses Keycloak via
a private network. If set to true, 'hostname' option needs to be specified
as a full URL. Default: false. Available only when hostname:v2 feature is
enabled.
--hostname-debug <true|false> --hostname-debug <true|false>
Toggle the hostname debug page that is accessible at Toggles the hostname debug page that is accessible at
/realms/master/hostname-debug Default: false. /realms/master/hostname-debug. Default: false. Available only when hostname:
--hostname-path <path> v2 feature is enabled.
This should be set if proxy uses a different context-path for Keycloak.
--hostname-port <port>
The port used by the proxy when exposing the hostname. Set this option if the
proxy uses a port other than the default HTTP and HTTPS ports. Default: -1.
--hostname-strict <true|false> --hostname-strict <true|false>
Disables dynamically resolving the hostname from request headers. Should Disables dynamically resolving the hostname from request headers. Should
always be set to true in production, unless proxy verifies the Host header. always be set to true in production, unless your reverse proxy overwrites
Default: true. the Host header. If enabled, the 'hostname' option needs to be specified.
--hostname-strict-backchannel <true|false> Default: true. Available only when hostname:v2 feature is enabled.
By default backchannel URLs are dynamically resolved from request headers to
allow internal and external applications. If all applications use the public
URL this option should be enabled. Default: false.
--hostname-url <url> Set the base URL for frontend URLs, including scheme, host, port and path.
HTTP(S): HTTP(S):

View file

@ -123,34 +123,73 @@ Feature:
--features-disabled <feature> --features-disabled <feature>
Disables a set of one or more features. Possible values are: <...>. Disables a set of one or more features. Possible values are: <...>.
Hostname: Hostname v2:
--hostname <hostname> --hostname <hostname|URL>
Hostname for the Keycloak server. Address at which is the server exposed. Can be a full URL, or just a hostname.
--hostname-admin <hostname> When only hostname is provided, scheme, port and context path are resolved
The hostname for accessing the administration console. Use this option if you from the request. Available only when hostname:v2 feature is enabled.
are exposing the administration console using a hostname other than the --hostname-admin <URL>
value set to the 'hostname' option. Address for accessing the administration console. Use this option if you are
--hostname-admin-url <url> exposing the administration console using a reverse proxy on a different
Set the base URL for accessing the administration console, including scheme, address than specified in the 'hostname' option. Available only when
host, port and path hostname:v2 feature is enabled.
--hostname-backchannel-dynamic <true|false>
Enables dynamic resolving of backchannel URLs, including hostname, scheme,
port and context path. Set to true if your application accesses Keycloak via
a private network. If set to true, 'hostname' option needs to be specified
as a full URL. Default: false. Available only when hostname:v2 feature is
enabled.
--hostname-debug <true|false> --hostname-debug <true|false>
Toggle the hostname debug page that is accessible at Toggles the hostname debug page that is accessible at
/realms/master/hostname-debug Default: false. /realms/master/hostname-debug. Default: false. Available only when hostname:
--hostname-path <path> v2 feature is enabled.
This should be set if proxy uses a different context-path for Keycloak.
--hostname-port <port>
The port used by the proxy when exposing the hostname. Set this option if the
proxy uses a port other than the default HTTP and HTTPS ports. Default: -1.
--hostname-strict <true|false> --hostname-strict <true|false>
Disables dynamically resolving the hostname from request headers. Should Disables dynamically resolving the hostname from request headers. Should
always be set to true in production, unless proxy verifies the Host header. always be set to true in production, unless your reverse proxy overwrites
Default: true. the Host header. If enabled, the 'hostname' option needs to be specified.
Default: true. Available only when hostname:v2 feature is enabled.
Hostname v1 (Deprecated):
--hostname <hostname>
DEPRECATED. Hostname for the Keycloak server. Available only when hostname:v1
feature is enabled.
--hostname-admin <hostname>
DEPRECATED. The hostname for accessing the administration console. Use this
option if you are exposing the administration console using a hostname other
than the value set to the 'hostname' option. Available only when hostname:v1
feature is enabled.
--hostname-admin-url <url>
DEPRECATED. Set the base URL for accessing the administration console,
including scheme, host, port and path Available only when hostname:v1
feature is enabled.
--hostname-debug <true|false>
DEPRECATED. Toggle the hostname debug page that is accessible at
/realms/master/hostname-debug Default: false. Available only when hostname:
v1 feature is enabled.
--hostname-path <path>
DEPRECATED. This should be set if proxy uses a different context-path for
Keycloak. Available only when hostname:v1 feature is enabled.
--hostname-port <port>
DEPRECATED. The port used by the proxy when exposing the hostname. Set this
option if the proxy uses a port other than the default HTTP and HTTPS ports.
Default: -1. Available only when hostname:v1 feature is enabled.
--hostname-strict <true|false>
DEPRECATED. Disables dynamically resolving the hostname from request headers.
Should always be set to true in production, unless proxy verifies the Host
header. Default: true. Available only when hostname:v1 feature is enabled.
--hostname-strict-backchannel <true|false> --hostname-strict-backchannel <true|false>
By default backchannel URLs are dynamically resolved from request headers to DEPRECATED. By default backchannel URLs are dynamically resolved from request
allow internal and external applications. If all applications use the public headers to allow internal and external applications. If all applications use
URL this option should be enabled. Default: false. the public URL this option should be enabled. Default: false. Available only
--hostname-url <url> Set the base URL for frontend URLs, including scheme, host, port and path. when hostname:v1 feature is enabled.
--hostname-strict-https <true|false>
DEPRECATED. Forces frontend URLs to use the 'https' scheme. If set to false,
the HTTP scheme is inferred from requests. Default: true. Available only
when hostname:v1 feature is enabled.
--hostname-url <url> DEPRECATED. Set the base URL for frontend URLs, including scheme, host, port
and path. Available only when hostname:v1 feature is enabled.
HTTP(S): HTTP(S):

View file

@ -121,34 +121,32 @@ Feature:
--features-disabled <feature> --features-disabled <feature>
Disables a set of one or more features. Possible values are: <...>. Disables a set of one or more features. Possible values are: <...>.
Hostname: Hostname v2:
--hostname <hostname> --hostname <hostname|URL>
Hostname for the Keycloak server. Address at which is the server exposed. Can be a full URL, or just a hostname.
--hostname-admin <hostname> When only hostname is provided, scheme, port and context path are resolved
The hostname for accessing the administration console. Use this option if you from the request. Available only when hostname:v2 feature is enabled.
are exposing the administration console using a hostname other than the --hostname-admin <URL>
value set to the 'hostname' option. Address for accessing the administration console. Use this option if you are
--hostname-admin-url <url> exposing the administration console using a reverse proxy on a different
Set the base URL for accessing the administration console, including scheme, address than specified in the 'hostname' option. Available only when
host, port and path hostname:v2 feature is enabled.
--hostname-backchannel-dynamic <true|false>
Enables dynamic resolving of backchannel URLs, including hostname, scheme,
port and context path. Set to true if your application accesses Keycloak via
a private network. If set to true, 'hostname' option needs to be specified
as a full URL. Default: false. Available only when hostname:v2 feature is
enabled.
--hostname-debug <true|false> --hostname-debug <true|false>
Toggle the hostname debug page that is accessible at Toggles the hostname debug page that is accessible at
/realms/master/hostname-debug Default: false. /realms/master/hostname-debug. Default: false. Available only when hostname:
--hostname-path <path> v2 feature is enabled.
This should be set if proxy uses a different context-path for Keycloak.
--hostname-port <port>
The port used by the proxy when exposing the hostname. Set this option if the
proxy uses a port other than the default HTTP and HTTPS ports. Default: -1.
--hostname-strict <true|false> --hostname-strict <true|false>
Disables dynamically resolving the hostname from request headers. Should Disables dynamically resolving the hostname from request headers. Should
always be set to true in production, unless proxy verifies the Host header. always be set to true in production, unless your reverse proxy overwrites
Default: true. the Host header. If enabled, the 'hostname' option needs to be specified.
--hostname-strict-backchannel <true|false> Default: true. Available only when hostname:v2 feature is enabled.
By default backchannel URLs are dynamically resolved from request headers to
allow internal and external applications. If all applications use the public
URL this option should be enabled. Default: false.
--hostname-url <url> Set the base URL for frontend URLs, including scheme, host, port and path.
HTTP(S): HTTP(S):

View file

@ -124,34 +124,73 @@ Feature:
--features-disabled <feature> --features-disabled <feature>
Disables a set of one or more features. Possible values are: <...>. Disables a set of one or more features. Possible values are: <...>.
Hostname: Hostname v2:
--hostname <hostname> --hostname <hostname|URL>
Hostname for the Keycloak server. Address at which is the server exposed. Can be a full URL, or just a hostname.
--hostname-admin <hostname> When only hostname is provided, scheme, port and context path are resolved
The hostname for accessing the administration console. Use this option if you from the request. Available only when hostname:v2 feature is enabled.
are exposing the administration console using a hostname other than the --hostname-admin <URL>
value set to the 'hostname' option. Address for accessing the administration console. Use this option if you are
--hostname-admin-url <url> exposing the administration console using a reverse proxy on a different
Set the base URL for accessing the administration console, including scheme, address than specified in the 'hostname' option. Available only when
host, port and path hostname:v2 feature is enabled.
--hostname-backchannel-dynamic <true|false>
Enables dynamic resolving of backchannel URLs, including hostname, scheme,
port and context path. Set to true if your application accesses Keycloak via
a private network. If set to true, 'hostname' option needs to be specified
as a full URL. Default: false. Available only when hostname:v2 feature is
enabled.
--hostname-debug <true|false> --hostname-debug <true|false>
Toggle the hostname debug page that is accessible at Toggles the hostname debug page that is accessible at
/realms/master/hostname-debug Default: false. /realms/master/hostname-debug. Default: false. Available only when hostname:
--hostname-path <path> v2 feature is enabled.
This should be set if proxy uses a different context-path for Keycloak.
--hostname-port <port>
The port used by the proxy when exposing the hostname. Set this option if the
proxy uses a port other than the default HTTP and HTTPS ports. Default: -1.
--hostname-strict <true|false> --hostname-strict <true|false>
Disables dynamically resolving the hostname from request headers. Should Disables dynamically resolving the hostname from request headers. Should
always be set to true in production, unless proxy verifies the Host header. always be set to true in production, unless your reverse proxy overwrites
Default: true. the Host header. If enabled, the 'hostname' option needs to be specified.
Default: true. Available only when hostname:v2 feature is enabled.
Hostname v1 (Deprecated):
--hostname <hostname>
DEPRECATED. Hostname for the Keycloak server. Available only when hostname:v1
feature is enabled.
--hostname-admin <hostname>
DEPRECATED. The hostname for accessing the administration console. Use this
option if you are exposing the administration console using a hostname other
than the value set to the 'hostname' option. Available only when hostname:v1
feature is enabled.
--hostname-admin-url <url>
DEPRECATED. Set the base URL for accessing the administration console,
including scheme, host, port and path Available only when hostname:v1
feature is enabled.
--hostname-debug <true|false>
DEPRECATED. Toggle the hostname debug page that is accessible at
/realms/master/hostname-debug Default: false. Available only when hostname:
v1 feature is enabled.
--hostname-path <path>
DEPRECATED. This should be set if proxy uses a different context-path for
Keycloak. Available only when hostname:v1 feature is enabled.
--hostname-port <port>
DEPRECATED. The port used by the proxy when exposing the hostname. Set this
option if the proxy uses a port other than the default HTTP and HTTPS ports.
Default: -1. Available only when hostname:v1 feature is enabled.
--hostname-strict <true|false>
DEPRECATED. Disables dynamically resolving the hostname from request headers.
Should always be set to true in production, unless proxy verifies the Host
header. Default: true. Available only when hostname:v1 feature is enabled.
--hostname-strict-backchannel <true|false> --hostname-strict-backchannel <true|false>
By default backchannel URLs are dynamically resolved from request headers to DEPRECATED. By default backchannel URLs are dynamically resolved from request
allow internal and external applications. If all applications use the public headers to allow internal and external applications. If all applications use
URL this option should be enabled. Default: false. the public URL this option should be enabled. Default: false. Available only
--hostname-url <url> Set the base URL for frontend URLs, including scheme, host, port and path. when hostname:v1 feature is enabled.
--hostname-strict-https <true|false>
DEPRECATED. Forces frontend URLs to use the 'https' scheme. If set to false,
the HTTP scheme is inferred from requests. Default: true. Available only
when hostname:v1 feature is enabled.
--hostname-url <url> DEPRECATED. Set the base URL for frontend URLs, including scheme, host, port
and path. Available only when hostname:v1 feature is enabled.
HTTP(S): HTTP(S):

View file

@ -94,34 +94,32 @@ Database:
--db-username <username> --db-username <username>
The username of the database user. The username of the database user.
Hostname: Hostname v2:
--hostname <hostname> --hostname <hostname|URL>
Hostname for the Keycloak server. Address at which is the server exposed. Can be a full URL, or just a hostname.
--hostname-admin <hostname> When only hostname is provided, scheme, port and context path are resolved
The hostname for accessing the administration console. Use this option if you from the request. Available only when hostname:v2 feature is enabled.
are exposing the administration console using a hostname other than the --hostname-admin <URL>
value set to the 'hostname' option. Address for accessing the administration console. Use this option if you are
--hostname-admin-url <url> exposing the administration console using a reverse proxy on a different
Set the base URL for accessing the administration console, including scheme, address than specified in the 'hostname' option. Available only when
host, port and path hostname:v2 feature is enabled.
--hostname-backchannel-dynamic <true|false>
Enables dynamic resolving of backchannel URLs, including hostname, scheme,
port and context path. Set to true if your application accesses Keycloak via
a private network. If set to true, 'hostname' option needs to be specified
as a full URL. Default: false. Available only when hostname:v2 feature is
enabled.
--hostname-debug <true|false> --hostname-debug <true|false>
Toggle the hostname debug page that is accessible at Toggles the hostname debug page that is accessible at
/realms/master/hostname-debug Default: false. /realms/master/hostname-debug. Default: false. Available only when hostname:
--hostname-path <path> v2 feature is enabled.
This should be set if proxy uses a different context-path for Keycloak.
--hostname-port <port>
The port used by the proxy when exposing the hostname. Set this option if the
proxy uses a port other than the default HTTP and HTTPS ports. Default: -1.
--hostname-strict <true|false> --hostname-strict <true|false>
Disables dynamically resolving the hostname from request headers. Should Disables dynamically resolving the hostname from request headers. Should
always be set to true in production, unless proxy verifies the Host header. always be set to true in production, unless your reverse proxy overwrites
Default: true. the Host header. If enabled, the 'hostname' option needs to be specified.
--hostname-strict-backchannel <true|false> Default: true. Available only when hostname:v2 feature is enabled.
By default backchannel URLs are dynamically resolved from request headers to
allow internal and external applications. If all applications use the public
URL this option should be enabled. Default: false.
--hostname-url <url> Set the base URL for frontend URLs, including scheme, host, port and path.
HTTP(S): HTTP(S):

View file

@ -97,34 +97,73 @@ Database:
--db-username <username> --db-username <username>
The username of the database user. The username of the database user.
Hostname: Hostname v2:
--hostname <hostname> --hostname <hostname|URL>
Hostname for the Keycloak server. Address at which is the server exposed. Can be a full URL, or just a hostname.
--hostname-admin <hostname> When only hostname is provided, scheme, port and context path are resolved
The hostname for accessing the administration console. Use this option if you from the request. Available only when hostname:v2 feature is enabled.
are exposing the administration console using a hostname other than the --hostname-admin <URL>
value set to the 'hostname' option. Address for accessing the administration console. Use this option if you are
--hostname-admin-url <url> exposing the administration console using a reverse proxy on a different
Set the base URL for accessing the administration console, including scheme, address than specified in the 'hostname' option. Available only when
host, port and path hostname:v2 feature is enabled.
--hostname-backchannel-dynamic <true|false>
Enables dynamic resolving of backchannel URLs, including hostname, scheme,
port and context path. Set to true if your application accesses Keycloak via
a private network. If set to true, 'hostname' option needs to be specified
as a full URL. Default: false. Available only when hostname:v2 feature is
enabled.
--hostname-debug <true|false> --hostname-debug <true|false>
Toggle the hostname debug page that is accessible at Toggles the hostname debug page that is accessible at
/realms/master/hostname-debug Default: false. /realms/master/hostname-debug. Default: false. Available only when hostname:
--hostname-path <path> v2 feature is enabled.
This should be set if proxy uses a different context-path for Keycloak.
--hostname-port <port>
The port used by the proxy when exposing the hostname. Set this option if the
proxy uses a port other than the default HTTP and HTTPS ports. Default: -1.
--hostname-strict <true|false> --hostname-strict <true|false>
Disables dynamically resolving the hostname from request headers. Should Disables dynamically resolving the hostname from request headers. Should
always be set to true in production, unless proxy verifies the Host header. always be set to true in production, unless your reverse proxy overwrites
Default: true. the Host header. If enabled, the 'hostname' option needs to be specified.
Default: true. Available only when hostname:v2 feature is enabled.
Hostname v1 (Deprecated):
--hostname <hostname>
DEPRECATED. Hostname for the Keycloak server. Available only when hostname:v1
feature is enabled.
--hostname-admin <hostname>
DEPRECATED. The hostname for accessing the administration console. Use this
option if you are exposing the administration console using a hostname other
than the value set to the 'hostname' option. Available only when hostname:v1
feature is enabled.
--hostname-admin-url <url>
DEPRECATED. Set the base URL for accessing the administration console,
including scheme, host, port and path Available only when hostname:v1
feature is enabled.
--hostname-debug <true|false>
DEPRECATED. Toggle the hostname debug page that is accessible at
/realms/master/hostname-debug Default: false. Available only when hostname:
v1 feature is enabled.
--hostname-path <path>
DEPRECATED. This should be set if proxy uses a different context-path for
Keycloak. Available only when hostname:v1 feature is enabled.
--hostname-port <port>
DEPRECATED. The port used by the proxy when exposing the hostname. Set this
option if the proxy uses a port other than the default HTTP and HTTPS ports.
Default: -1. Available only when hostname:v1 feature is enabled.
--hostname-strict <true|false>
DEPRECATED. Disables dynamically resolving the hostname from request headers.
Should always be set to true in production, unless proxy verifies the Host
header. Default: true. Available only when hostname:v1 feature is enabled.
--hostname-strict-backchannel <true|false> --hostname-strict-backchannel <true|false>
By default backchannel URLs are dynamically resolved from request headers to DEPRECATED. By default backchannel URLs are dynamically resolved from request
allow internal and external applications. If all applications use the public headers to allow internal and external applications. If all applications use
URL this option should be enabled. Default: false. the public URL this option should be enabled. Default: false. Available only
--hostname-url <url> Set the base URL for frontend URLs, including scheme, host, port and path. when hostname:v1 feature is enabled.
--hostname-strict-https <true|false>
DEPRECATED. Forces frontend URLs to use the 'https' scheme. If set to false,
the HTTP scheme is inferred from requests. Default: true. Available only
when hostname:v1 feature is enabled.
--hostname-url <url> DEPRECATED. Set the base URL for frontend URLs, including scheme, host, port
and path. Available only when hostname:v1 feature is enabled.
HTTP(S): HTTP(S):

View file

@ -99,4 +99,12 @@ public class StringUtil {
} }
return sb == null? str : sb.toString(); return sb == null? str : sb.toString();
} }
public static String removeSuffix(String str, String suffix) {
int index = str.lastIndexOf(suffix);
if (str.endsWith(suffix) && index > 0) {
str = str.substring(0, index);
}
return str;
}
} }

View file

@ -1,122 +0,0 @@
package org.keycloak.url;
import static org.keycloak.common.util.UriUtils.checkUrl;
import static org.keycloak.utils.StringUtil.isNotBlank;
import org.jboss.logging.Logger;
import org.keycloak.common.enums.SslRequired;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.urls.HostnameProvider;
import org.keycloak.urls.UrlType;
import jakarta.ws.rs.core.UriInfo;
import java.net.URI;
public class DefaultHostnameProvider implements HostnameProvider {
private static final Logger LOGGER = Logger.getLogger(DefaultHostnameProvider.class);
private final KeycloakSession session;
private final URI frontendUri;
private String currentRealm;
private URI realmUri;
private URI adminUri;
private URI localAdminUri = URI.create("http://localhost:8080/auth");
private final boolean forceBackendUrlToFrontendUrl;
public DefaultHostnameProvider(KeycloakSession session, URI frontendUri, URI adminUri, boolean forceBackendUrlToFrontendUrl) {
this.session = session;
this.frontendUri = frontendUri;
this.adminUri = adminUri;
this.forceBackendUrlToFrontendUrl = forceBackendUrlToFrontendUrl;
}
@Override
public String getScheme(UriInfo originalUriInfo, UrlType type) {
return resolveUri(originalUriInfo, type).getScheme();
}
@Override
public String getHostname(UriInfo originalUriInfo, UrlType type) {
return resolveUri(originalUriInfo, type).getHost();
}
@Override
public int getPort(UriInfo originalUriInfo, UrlType type) {
return resolveUri(originalUriInfo, type).getPort();
}
@Override
public String getContextPath(UriInfo originalUriInfo, UrlType type) {
return resolveUri(originalUriInfo, type).getPath();
}
private URI resolveUri(UriInfo originalUriInfo, UrlType type) {
if (type.equals(UrlType.LOCAL_ADMIN)) {
return localAdminUri;
}
URI realmUri = getRealmUri();
URI frontendUri = realmUri != null ? realmUri : this.frontendUri;
// Use frontend URI for backend requests if forceBackendUrlToFrontendUrl is true
if (type.equals(UrlType.BACKEND) && forceBackendUrlToFrontendUrl) {
type = UrlType.FRONTEND;
}
// Use frontend URI for backend requests if request hostname matches frontend hostname
if (type.equals(UrlType.BACKEND) && frontendUri != null && originalUriInfo.getBaseUri().getHost().equals(frontendUri.getHost())) {
type = UrlType.FRONTEND;
}
// Use frontend URI for admin requests if adminUrl not set
if (type.equals(UrlType.ADMIN)) {
if (adminUri != null) {
return adminUri;
} else {
type = UrlType.FRONTEND;
}
}
if (type.equals(UrlType.FRONTEND) && frontendUri != null) {
return frontendUri;
}
return originalUriInfo.getBaseUri();
}
private URI getRealmUri() {
RealmModel realm = session.getContext().getRealm();
if (realm == null) {
currentRealm = null;
realmUri = null;
return null;
} else if (realm.getId().equals(currentRealm)) {
return realmUri;
} else {
currentRealm = realm.getId();
realmUri = null;
String realmFrontendUrl = session.getContext().getRealm().getAttribute("frontendUrl");
if (isNotBlank(realmFrontendUrl)) {
try {
checkUrl(SslRequired.NONE, realmFrontendUrl, "frontendUrl");
realmUri = URI.create(realmFrontendUrl);
} catch (IllegalArgumentException e) {
LOGGER.errorf(e, "Failed to parse realm frontendUrl '%s'. Falling back to global value.", realmFrontendUrl);
}
}
return realmUri;
}
}
}

View file

@ -1,56 +0,0 @@
package org.keycloak.url;
import org.jboss.logging.Logger;
import org.keycloak.Config;
import org.keycloak.models.KeycloakSession;
import org.keycloak.urls.HostnameProvider;
import org.keycloak.urls.HostnameProviderFactory;
import java.net.URI;
import java.net.URISyntaxException;
public class DefaultHostnameProviderFactory implements HostnameProviderFactory {
private static final Logger LOGGER = Logger.getLogger(DefaultHostnameProviderFactory.class);
private URI frontendUri;
private URI adminUri;
private boolean forceBackendUrlToFrontendUrl;
@Override
public HostnameProvider create(KeycloakSession session) {
return new DefaultHostnameProvider(session, frontendUri, adminUri, forceBackendUrlToFrontendUrl);
}
@Override
public void init(Config.Scope config) {
String frontendUrl = config.get("frontendUrl");
String adminUrl = config.get("adminUrl");
if (frontendUrl != null && !frontendUrl.isEmpty()) {
try {
frontendUri = new URI(frontendUrl);
} catch (URISyntaxException e) {
throw new RuntimeException("Invalid value for frontendUrl", e);
}
}
if (adminUrl != null && !adminUrl.isEmpty()) {
try {
adminUri = new URI(adminUrl);
} catch (URISyntaxException e) {
throw new RuntimeException("Invalid value for adminUrl", e);
}
}
forceBackendUrlToFrontendUrl = config.getBoolean("forceBackendUrlToFrontendUrl", false);
LOGGER.infov("Frontend: {0}, Admin: {1}, Backend: {2}", frontendUri != null ? frontendUri.toString() : "<request>", adminUri != null ? adminUri.toString() : "<frontend>", forceBackendUrlToFrontendUrl ? "<frontend>" : "<request>");
}
@Override
public String getId() {
return "default";
}
}

View file

@ -1,71 +0,0 @@
package org.keycloak.url;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.urls.HostnameProvider;
import jakarta.ws.rs.core.UriInfo;
@Deprecated
public class FixedHostnameProvider implements HostnameProvider {
private final KeycloakSession session;
private final String globalHostname;
private final boolean alwaysHttps;
private final int httpPort;
private final int httpsPort;
public FixedHostnameProvider(KeycloakSession session, boolean alwaysHttps, String globalHostname, int httpPort, int httpsPort) {
this.session = session;
this.alwaysHttps = alwaysHttps;
this.globalHostname = globalHostname;
this.httpPort = httpPort;
this.httpsPort = httpsPort;
}
@Override
public String getScheme(UriInfo originalUriInfo) {
return alwaysHttps ? "https" : originalUriInfo.getRequestUri().getScheme();
}
@Override
public String getHostname(UriInfo originalUriInfo) {
RealmModel realm = session.getContext().getRealm();
if (realm != null) {
String realmHostname = session.getContext().getRealm().getAttribute("hostname");
if (realmHostname != null && !realmHostname.isEmpty()) {
return realmHostname;
}
}
return this.globalHostname;
}
@Override
public int getPort(UriInfo originalUriInfo) {
boolean https = originalUriInfo.getRequestUri().getScheme().equals("https");
if (https) {
if (httpsPort == -1) {
return originalUriInfo.getRequestUri().getPort();
} else if (httpsPort == 443) {
return -1;
} else {
return httpsPort;
}
} else if (alwaysHttps) {
if (httpsPort == 443) {
return -1;
} else {
return httpsPort;
}
} else {
if (httpPort == -1) {
return originalUriInfo.getRequestUri().getPort();
} else if (httpPort == 80) {
return -1;
} else {
return httpPort;
}
}
}
}

View file

@ -1,44 +0,0 @@
package org.keycloak.url;
import org.jboss.logging.Logger;
import org.keycloak.Config;
import org.keycloak.models.KeycloakSession;
import org.keycloak.urls.HostnameProvider;
import org.keycloak.urls.HostnameProviderFactory;
@Deprecated
public class FixedHostnameProviderFactory implements HostnameProviderFactory {
private static final Logger LOGGER = Logger.getLogger(RequestHostnameProviderFactory.class);
private boolean loggedDeprecatedWarning = false;
private String hostname;
private int httpPort;
private int httpsPort;
private boolean alwaysHttps;
@Override
public HostnameProvider create(KeycloakSession session) {
if (!loggedDeprecatedWarning) {
loggedDeprecatedWarning = true;
LOGGER.warn("fixed hostname provider is deprecated, please switch to the default hostname provider");
}
return new FixedHostnameProvider(session, alwaysHttps, hostname, httpPort, httpsPort);
}
@Override
public void init(Config.Scope config) {
this.hostname = config.get("hostname");
this.httpPort = config.getInt("httpPort", -1);
this.httpsPort = config.getInt("httpsPort", -1);
this.alwaysHttps = config.getBoolean("alwaysHttps", false);
}
@Override
public String getId() {
return "fixed";
}
}

View file

@ -0,0 +1,171 @@
/*
* Copyright 2024 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.url;
import jakarta.ws.rs.core.UriBuilder;
import jakarta.ws.rs.core.UriInfo;
import org.jboss.logging.Logger;
import org.keycloak.common.enums.SslRequired;
import org.keycloak.models.KeycloakSession;
import org.keycloak.urls.HostnameProvider;
import org.keycloak.urls.UrlType;
import java.net.URI;
import java.util.Optional;
import static org.keycloak.common.util.UriUtils.checkUrl;
import static org.keycloak.urls.UrlType.FRONTEND;
import static org.keycloak.utils.StringUtil.isNotBlank;
/**
* @author Vaclav Muzikar <vmuzikar@redhat.com>
*/
public class HostnameV2Provider implements HostnameProvider {
private final KeycloakSession session;
private final String hostname;
private final URI hostnameUrl;
private final URI adminUrl;
private final Boolean backchannelDynamic;
private static final UrlType defaultUrlType = FRONTEND;
private final Logger logger = Logger.getLogger(HostnameV2Provider.class);
public HostnameV2Provider(KeycloakSession session, String hostname, URI hostnameUrl, URI adminUrl, Boolean backchannelDynamic) {
this.session = session;
this.hostname = hostname;
this.hostnameUrl = hostnameUrl;
this.adminUrl = adminUrl;
this.backchannelDynamic = backchannelDynamic;
}
private URI getUri(UriInfo originalUriInfo, UrlType type) {
UriBuilder builder;
switch (type) {
case ADMIN:
builder = getAdminUriBuilder(originalUriInfo);
break;
case LOCAL_ADMIN:
builder = originalUriInfo.getBaseUriBuilder();
// This might not be enough if a reverse proxy is used. In that case we might e.g. have wrong local ports in originalUriInfo.
// However, that would be transparent to us (we don't know the actual server ports in this context AFAIK).
builder.host("localhost");
break;
case BACKEND:
builder = backchannelDynamic ? originalUriInfo.getBaseUriBuilder() : getFrontUriBuilder(originalUriInfo);
break;
case FRONTEND:
builder = getFrontUriBuilder(originalUriInfo);
break;
default:
throw new IllegalArgumentException("Unknown URL type");
}
// sanitize ports
URI uriPeak = builder.build();
if ((uriPeak.getScheme().equals("http") && uriPeak.getPort() == 80) || (uriPeak.getScheme().equals("https") && uriPeak.getPort() == 443)) {
builder.port(-1);
}
return builder.build();
}
private UriBuilder getFrontUriBuilder(UriInfo originalUriInfo) {
UriBuilder builder = getRealmFrontUriBuilder();
if (builder != null) {
return builder;
}
if (hostnameUrl != null) {
builder = UriBuilder.fromUri(hostnameUrl);
}
else {
builder = originalUriInfo.getBaseUriBuilder();
if (hostname != null) {
builder.host(hostname);
}
}
return builder;
}
private UriBuilder getRealmFrontUriBuilder() {
return Optional.ofNullable(session)
.map(s -> s.getContext())
.map(c -> c.getRealm())
.map(r -> r.getAttribute("frontendUrl"))
.filter(url -> isNotBlank(url))
.filter(url -> {
try {
// this check is aligned with other Hostname providers to avoid breaking changes; note that checking URL this way is considered insufficient, see e.g. https://stackoverflow.com/a/5965755
checkUrl(SslRequired.NONE, url, "Realm frontendUrl");
}
catch (IllegalArgumentException e) {
logger.errorf(e, "Failed to parse realm frontendUrl '%s'. Falling back to global value.", url);
return false;
}
return true;
})
.map(UriBuilder::fromUri)
.orElse(null);
}
private UriBuilder getAdminUriBuilder(UriInfo originalUriInfo) {
return adminUrl != null ? UriBuilder.fromUri(adminUrl) : getFrontUriBuilder(originalUriInfo);
}
@Override
public String getScheme(UriInfo originalUriInfo, UrlType type) {
return getUri(originalUriInfo, type).getScheme();
}
@Override
public String getScheme(UriInfo originalUriInfo) {
return getScheme(originalUriInfo, defaultUrlType);
}
@Override
public String getHostname(UriInfo originalUriInfo, UrlType type) {
return getUri(originalUriInfo, type).getHost();
}
@Override
public String getHostname(UriInfo originalUriInfo) {
return getHostname(originalUriInfo, defaultUrlType);
}
@Override
public int getPort(UriInfo originalUriInfo, UrlType type) {
return getUri(originalUriInfo, type).getPort();
}
@Override
public int getPort(UriInfo originalUriInfo) {
return getPort(originalUriInfo, defaultUrlType);
}
@Override
public String getContextPath(UriInfo originalUriInfo, UrlType type) {
return getUri(originalUriInfo, type).getPath();
}
@Override
public String getContextPath(UriInfo originalUriInfo) {
return getContextPath(originalUriInfo, defaultUrlType);
}
}

View file

@ -0,0 +1,108 @@
/*
* Copyright 2024 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.url;
import org.keycloak.Config;
import org.keycloak.common.Profile;
import org.keycloak.models.KeycloakSession;
import org.keycloak.provider.EnvironmentDependentProviderFactory;
import org.keycloak.urls.HostnameProvider;
import org.keycloak.urls.HostnameProviderFactory;
import java.net.URI;
import java.util.Optional;
import java.util.regex.Pattern;
/**
* @author Vaclav Muzikar <vmuzikar@redhat.com>
*/
public class HostnameV2ProviderFactory implements HostnameProviderFactory, EnvironmentDependentProviderFactory {
private String hostname;
private URI hostnameUrl;
private URI adminUrl;
private Boolean backchannelDynamic;
// Simplified regexes for hostname validations; further validations are performed when instantiating URI object
private static final String hostnameStringPattern = "[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*";
private static final Pattern hostnamePattern = Pattern.compile("^" + hostnameStringPattern + "$");
private static final Pattern hostnameUrlPattern = Pattern.compile("^(http|https)://" + hostnameStringPattern + "(:\\d+)?(/[\\w-]+)*/?$");
@Override
public void init(Config.Scope config) {
// Strict mode is used just for enforcing that hostname is set
Boolean strictMode = config.getBoolean("hostname-strict", false);
String hostnameRaw = config.get("hostname");
if (strictMode && hostnameRaw == null) {
throw new IllegalArgumentException("hostname is not configured; either configure hostname, or set hostname-strict to false");
} else if (hostnameRaw != null && !strictMode) {
// We might not need this validation as it doesn't matter in this case if strict is true or false. It's just for consistency hostname XOR !strict.
// throw new IllegalArgumentException("hostname is configured, hostname-strict must be set to true");
}
// Set hostname, can be either a full URL, or just hostname
if (hostnameRaw != null) {
if (hostnamePattern.matcher(hostnameRaw).matches()) {
hostname = hostnameRaw;
}
else {
hostnameUrl = validateAndCreateUri(hostnameRaw, "Provided hostname is neither a plain hostname or a valid URL");
}
}
Optional.ofNullable(config.get("hostname-admin")).ifPresent(h ->
adminUrl = validateAndCreateUri(h, "Provided hostname-admin is not a valid URL"));
// Dynamic backchannel requires hostname to be specified as full URL. Otherwise we might end up with some parts of the
// backend request in frontend URLs. Therefore frontend (and admin) needs to be fully static.
backchannelDynamic = config.getBoolean("hostname-backchannel-dynamic", false);
if (hostname == null && hostnameUrl == null && backchannelDynamic) {
throw new IllegalArgumentException("hostname-backchannel-dynamic must be set to false when no hostname is provided");
}
if (backchannelDynamic && hostnameUrl == null) {
throw new IllegalArgumentException("hostname-backchannel-dynamic must be set to false if hostname is not provided as full URL");
}
}
private URI validateAndCreateUri(String uri, String validationFailedMessage) {
if (!hostnameUrlPattern.matcher(uri).matches()) {
throw new IllegalArgumentException(validationFailedMessage);
}
try {
return URI.create(uri);
}
catch (IllegalArgumentException e) {
throw new IllegalArgumentException(validationFailedMessage, e);
}
}
@Override
public HostnameProvider create(KeycloakSession session) {
return new HostnameV2Provider(session, hostname, hostnameUrl, adminUrl, backchannelDynamic);
}
@Override
public String getId() {
return "v2";
}
@Override
public boolean isSupported() {
return Profile.isFeatureEnabled(Profile.Feature.HOSTNAME_V2);
}
}

View file

@ -1,25 +0,0 @@
package org.keycloak.url;
import org.keycloak.urls.HostnameProvider;
import jakarta.ws.rs.core.UriInfo;
@Deprecated
public class RequestHostnameProvider implements HostnameProvider {
@Override
public String getScheme(UriInfo originalUriInfo) {
return originalUriInfo.getRequestUri().getScheme();
}
@Override
public String getHostname(UriInfo originalUriInfo) {
return originalUriInfo.getBaseUri().getHost();
}
@Override
public int getPort(UriInfo originalUriInfo) {
return originalUriInfo.getRequestUri().getPort();
}
}

View file

@ -1,32 +0,0 @@
package org.keycloak.url;
import org.jboss.logging.Logger;
import org.keycloak.models.KeycloakSession;
import org.keycloak.urls.HostnameProvider;
import org.keycloak.urls.HostnameProviderFactory;
import jakarta.ws.rs.core.UriInfo;
@Deprecated
public class RequestHostnameProviderFactory implements HostnameProviderFactory {
private static final Logger LOGGER = Logger.getLogger(RequestHostnameProviderFactory.class);
private boolean loggedDeprecatedWarning = false;
@Override
public HostnameProvider create(KeycloakSession session) {
if (!loggedDeprecatedWarning) {
loggedDeprecatedWarning = true;
LOGGER.warn("request hostname provider is deprecated, please switch to the default hostname provider");
}
return new RequestHostnameProvider();
}
@Override
public String getId() {
return "request";
}
}

View file

@ -1,3 +1 @@
org.keycloak.url.DefaultHostnameProviderFactory org.keycloak.url.HostnameV2ProviderFactory
org.keycloak.url.FixedHostnameProviderFactory
org.keycloak.url.RequestHostnameProviderFactory

View file

@ -8,7 +8,6 @@ http-enabled=true
# Disables strict hostname # Disables strict hostname
hostname-strict=false hostname-strict=false
hostname-strict-https=false
# SSL # SSL
https-key-store-file=${kc.home.dir}/conf/keycloak.jks https-key-store-file=${kc.home.dir}/conf/keycloak.jks
@ -19,7 +18,7 @@ https-client-auth=request
# Proxy # Proxy
# Using any proxy setting which evaluates the forward proxy header # Using any proxy setting which evaluates the forward proxy header
proxy=reencrypt proxy-headers=xforwarded
# Hostname Provider # Hostname Provider
spi-hostname-default-frontend-url = ${keycloak.frontendUrl:} spi-hostname-default-frontend-url = ${keycloak.frontendUrl:}

View file

@ -249,10 +249,17 @@ public class KeycloakOnUndertow implements DeployableContainer<KeycloakOnUnderto
} }
log.info("Stopping auth server."); log.info("Stopping auth server.");
if (sessionFactory != null) {
sessionFactory.close(); sessionFactory.close();
}
if (undertow != null) {
undertow.stop(); undertow.stop();
} }
sessionFactory = null;
undertow = null;
}
private boolean isRemoteMode() { private boolean isRemoteMode() {
//return true; //return true;
return configuration.isRemoteMode(); return configuration.isRemoteMode();

View file

@ -284,7 +284,7 @@ public abstract class AbstractQuarkusDeployableContainer implements DeployableCo
additionalBuildArgs = Collections.emptyList(); additionalBuildArgs = Collections.emptyList();
} }
protected void waitForReadiness() throws MalformedURLException, LifecycleException { protected void waitForReadiness() throws Exception {
SuiteContext suiteContext = this.suiteContext.get(); SuiteContext suiteContext = this.suiteContext.get();
//TODO: not sure if the best endpoint but it makes sure that everything is properly initialized. Once we have //TODO: not sure if the best endpoint but it makes sure that everything is properly initialized. Once we have
// support for MP Health this should change // support for MP Health this should change
@ -298,6 +298,8 @@ public abstract class AbstractQuarkusDeployableContainer implements DeployableCo
throw new IllegalStateException("Timeout [" + getStartTimeout() + "] while waiting for Quarkus server"); throw new IllegalStateException("Timeout [" + getStartTimeout() + "] while waiting for Quarkus server");
} }
checkLiveness();
try { try {
// wait before checking for opening a new connection // wait before checking for opening a new connection
Thread.sleep(1000); Thread.sleep(1000);
@ -325,6 +327,8 @@ public abstract class AbstractQuarkusDeployableContainer implements DeployableCo
log.infof("Keycloak is ready at %s", contextRoot); log.infof("Keycloak is ready at %s", contextRoot);
} }
protected abstract void checkLiveness() throws Exception;
private URL getBaseUrl(SuiteContext suiteContext) throws MalformedURLException { private URL getBaseUrl(SuiteContext suiteContext) throws MalformedURLException {
URL baseUrl = suiteContext.getAuthServerInfo().getContextRoot(); URL baseUrl = suiteContext.getAuthServerInfo().getContextRoot();

View file

@ -49,7 +49,18 @@ public class KeycloakContainerFeaturesController {
public enum FeatureAction { public enum FeatureAction {
ENABLE(KeycloakTestingClient::enableFeature), ENABLE(KeycloakTestingClient::enableFeature),
DISABLE(KeycloakTestingClient::disableFeature); ENABLE_AND_RESET((c, f) -> {
c.enableFeature(f);
// Without reset, feature will be persisted resulting e.g. in versioned features being disabled which is an invalid operation.
// At the same time we can't just reset the feature as we don't know in the server context whether the feature should be enabled
// or disabled after the reset.
c.resetFeature(f);
}),
DISABLE(KeycloakTestingClient::disableFeature),
DISABLE_AND_RESET((c, f) -> {
c.disableFeature(f);
c.resetFeature(f);
});
private BiConsumer<KeycloakTestingClient, Profile.Feature> featureConsumer; private BiConsumer<KeycloakTestingClient, Profile.Feature> featureConsumer;
@ -85,17 +96,18 @@ public class KeycloakContainerFeaturesController {
" feature " + feature.getKey() + ", however after performing this operation " + " feature " + feature.getKey() + ", however after performing this operation " +
"the feature is not in desired state" , "the feature is not in desired state" ,
ProfileAssume.isFeatureEnabled(feature), ProfileAssume.isFeatureEnabled(feature),
is(action == FeatureAction.ENABLE)); is(action == FeatureAction.ENABLE || action == FeatureAction.ENABLE_AND_RESET));
} }
public void performAction() { public void performAction() {
if ((action == FeatureAction.ENABLE && !ProfileAssume.isFeatureEnabled(feature)) if ((action == FeatureAction.ENABLE && !ProfileAssume.isFeatureEnabled(feature))
|| (action == FeatureAction.DISABLE && ProfileAssume.isFeatureEnabled(feature))) { || (action == FeatureAction.DISABLE && ProfileAssume.isFeatureEnabled(feature))
|| action == FeatureAction.ENABLE_AND_RESET || action == FeatureAction.DISABLE_AND_RESET) {
action.accept(testContextInstance.get().getTestingClient(), feature); action.accept(testContextInstance.get().getTestingClient(), feature);
SetDefaultProvider setDefaultProvider = annotatedElement.getAnnotation(SetDefaultProvider.class); SetDefaultProvider setDefaultProvider = annotatedElement.getAnnotation(SetDefaultProvider.class);
if (setDefaultProvider != null) { if (setDefaultProvider != null) {
try { try {
if (action == FeatureAction.ENABLE) { if (action == FeatureAction.ENABLE || action == FeatureAction.ENABLE_AND_RESET) {
SpiProvidersSwitchingUtils.addProviderDefaultValue(suiteContextInstance.get(), setDefaultProvider); SpiProvidersSwitchingUtils.addProviderDefaultValue(suiteContextInstance.get(), setDefaultProvider);
} else { } else {
SpiProvidersSwitchingUtils.removeProvider(suiteContextInstance.get(), setDefaultProvider); SpiProvidersSwitchingUtils.removeProvider(suiteContextInstance.get(), setDefaultProvider);
@ -178,12 +190,12 @@ public class KeycloakContainerFeaturesController {
ret.addAll(Arrays.stream(annotatedElement.getAnnotationsByType(EnableFeature.class)) ret.addAll(Arrays.stream(annotatedElement.getAnnotationsByType(EnableFeature.class))
.map(annotation -> new UpdateFeature(annotation.value(), annotation.skipRestart(), .map(annotation -> new UpdateFeature(annotation.value(), annotation.skipRestart(),
state == State.BEFORE ? FeatureAction.ENABLE : FeatureAction.DISABLE, annotatedElement)) state == State.BEFORE ? FeatureAction.ENABLE : FeatureAction.DISABLE_AND_RESET, annotatedElement))
.collect(Collectors.toSet())); .collect(Collectors.toSet()));
ret.addAll(Arrays.stream(annotatedElement.getAnnotationsByType(DisableFeature.class)) ret.addAll(Arrays.stream(annotatedElement.getAnnotationsByType(DisableFeature.class))
.map(annotation -> new UpdateFeature(annotation.value(), annotation.skipRestart(), .map(annotation -> new UpdateFeature(annotation.value(), annotation.skipRestart(),
state == State.BEFORE ? FeatureAction.DISABLE : FeatureAction.ENABLE, annotatedElement)) state == State.BEFORE ? FeatureAction.DISABLE : FeatureAction.ENABLE_AND_RESET, annotatedElement))
.collect(Collectors.toSet())); .collect(Collectors.toSet()));
return ret; return ret;

View file

@ -53,4 +53,9 @@ public class KeycloakQuarkusEmbeddedDeployableContainer extends AbstractQuarkusD
System.setProperty("quarkus.http.test-ssl-port", String.valueOf(configuration.getBindHttpsPort())); System.setProperty("quarkus.http.test-ssl-port", String.valueOf(configuration.getBindHttpsPort()));
return args; return args;
} }
@Override
protected void checkLiveness() {
// no-op, Keycloak would throw an exception in the test JVM if something went wrong
}
} }

View file

@ -1,9 +1,16 @@
package org.keycloak.testsuite.arquillian.containers; package org.keycloak.testsuite.arquillian.containers;
import org.jboss.arquillian.container.spi.client.container.LifecycleException;
import org.jboss.logging.Logger;
import org.keycloak.testsuite.model.StoreProvider;
import org.keycloak.testsuite.util.WaitUtils;
import java.io.BufferedReader;
import java.io.BufferedWriter; import java.io.BufferedWriter;
import java.io.File; import java.io.File;
import java.io.FileWriter; import java.io.FileWriter;
import java.io.IOException; import java.io.IOException;
import java.io.InputStreamReader;
import java.net.URISyntaxException; import java.net.URISyntaxException;
import java.net.URL; import java.net.URL;
import java.nio.file.FileVisitResult; import java.nio.file.FileVisitResult;
@ -23,16 +30,10 @@ import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import org.apache.commons.exec.StreamPumper;
import org.jboss.arquillian.container.spi.client.container.LifecycleException;
import org.jboss.logging.Logger;
import org.keycloak.testsuite.model.StoreProvider;
import org.keycloak.testsuite.util.WaitUtils;
/** /**
* @author mhajas * @author mhajas
*/ */
public class KeycloakQuarkusServerDeployableContainer extends AbstractQuarkusDeployableContainer { public class KeycloakQuarkusServerDeployableContainer extends AbstractQuarkusDeployableContainer implements RemoteContainer {
private static final int DEFAULT_SHUTDOWN_TIMEOUT_SECONDS = 10; private static final int DEFAULT_SHUTDOWN_TIMEOUT_SECONDS = 10;
@ -40,13 +41,15 @@ public class KeycloakQuarkusServerDeployableContainer extends AbstractQuarkusDep
private Process container; private Process container;
private Thread stdoutForwarderThread; private Thread stdoutForwarderThread;
private LogProcessor logProcessor;
@Override @Override
public void start() throws LifecycleException { public void start() throws LifecycleException {
try { try {
importRealm(); importRealm();
container = startContainer(); container = startContainer();
stdoutForwarderThread = new Thread(new StreamPumper(container.getInputStream(), System.out)); logProcessor = new LogProcessor(new BufferedReader(new InputStreamReader(container.getInputStream())));
stdoutForwarderThread = new Thread(logProcessor);
stdoutForwarderThread.start(); stdoutForwarderThread.start();
waitForReadiness(); waitForReadiness();
} catch (Exception e) { } catch (Exception e) {
@ -256,4 +259,48 @@ public class KeycloakQuarkusServerDeployableContainer extends AbstractQuarkusDep
}); });
} }
} }
@Override
protected void checkLiveness() {
if (!container.isAlive()) {
throw new IllegalStateException("Keycloak unexpectedly died :(");
}
}
@Override
public String getRemoteLog() {
return logProcessor.getBufferedLog();
}
private static class LogProcessor implements Runnable {
public static final int MAX_LOGGED_LINES = 512;
private List<String> loggedLines = new ArrayList<>();
private BufferedReader inputReader;
public LogProcessor(BufferedReader inputReader) {
this.inputReader = inputReader;
}
@Override
public void run() {
String line;
try {
while ((line = inputReader.readLine()) != null) {
System.out.println(line);
loggedLines.add(line);
if (loggedLines.size() > MAX_LOGGED_LINES) {
loggedLines.remove(0);
}
}
}
catch (IOException e) {
throw new RuntimeException(e);
}
}
public String getBufferedLog() {
return String.join("\n", loggedLines);
}
}
} }

View file

@ -0,0 +1,25 @@
/*
* Copyright 2024 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.testsuite.arquillian.containers;
/**
* @author Vaclav Muzikar <vmuzikar@redhat.com>
*/
public interface RemoteContainer {
String getRemoteLog();
}

View file

@ -36,10 +36,10 @@ public class WelcomePage extends AuthServer {
@FindBy(id = "password") @FindBy(id = "password")
private WebElement passwordInput; private WebElement passwordInput;
@FindBy(id = "passwordConfirmation") @FindBy(id = "password-confirmation")
private WebElement passwordConfirmationInput; private WebElement passwordConfirmationInput;
@FindBy(id = "create-button") @FindBy(tagName = "button")
private WebElement createButton; private WebElement createButton;
@FindBy(css = ".welcome-header h1") @FindBy(css = ".welcome-header h1")
@ -47,7 +47,8 @@ public class WelcomePage extends AuthServer {
public boolean isPasswordSet() { public boolean isPasswordSet() {
return !(driver.getPageSource().contains("Please create an initial admin user to get started.") || return !(driver.getPageSource().contains("Please create an initial admin user to get started.") ||
driver.getPageSource().contains("You need local access to create the initial admin user.")); driver.getPageSource().contains("You need local access to create the initial admin user.") ||
driver.getPageSource().contains("To get started with Keycloak, you first create an administrative user."));
} }
public void setPassword(String username, String password) { public void setPassword(String username, String password) {

View file

@ -86,7 +86,9 @@ public class UmaDiscoveryDocumentTest extends AbstractKeycloakTest {
test.setAttributes(new HashMap<>()); test.setAttributes(new HashMap<>());
} }
test.getAttributes().put("frontendUrl", "https://mykeycloak/auth"); final String frontendUrl = "https://mykeycloak/auth";
test.getAttributes().put("frontendUrl", frontendUrl);
realmsResouce().realm("test").update(test); realmsResouce().realm("test").update(test);
@ -101,12 +103,13 @@ public class UmaDiscoveryDocumentTest extends AbstractKeycloakTest {
UmaConfiguration configuration = response.readEntity(UmaConfiguration.class); UmaConfiguration configuration = response.readEntity(UmaConfiguration.class);
String baseBackendUri = UriBuilder String baseBackendUri = UriBuilder
.fromUri(OAuthClient.AUTH_SERVER_ROOT) .fromUri(frontendUrl)
.path(RealmsResource.class).path(RealmsResource.class, "getRealmResource").build(realmsResouce().realm("test").toRepresentation().getRealm()).toString(); .path(RealmsResource.class).path(RealmsResource.class, "getRealmResource").build(realmsResouce().realm("test").toRepresentation().getRealm()).toString();
String baseFrontendUri = UriBuilder String baseFrontendUri = UriBuilder
.fromUri(OAuthClient.AUTH_SERVER_ROOT) .fromUri(frontendUrl)
.path(RealmsResource.class).path(RealmsResource.class, "getRealmResource").scheme("https").host("mykeycloak").port(-1).build(realmsResouce().realm("test").toRepresentation().getRealm()).toString(); .path(RealmsResource.class).path(RealmsResource.class, "getRealmResource").scheme("https").host("mykeycloak").port(-1).build(realmsResouce().realm("test").toRepresentation().getRealm()).toString();
// we're not setting hostname-backchannel-dynamic=true which implies frontend URL is used for backend as well
assertEquals(baseBackendUri + "/authz/protection/permission", configuration.getPermissionEndpoint()); assertEquals(baseBackendUri + "/authz/protection/permission", configuration.getPermissionEndpoint());
assertEquals(baseBackendUri + "/authz/protection/permission", configuration.getPermissionEndpoint()); assertEquals(baseBackendUri + "/authz/protection/permission", configuration.getPermissionEndpoint());
assertEquals(baseFrontendUri + "/protocol/openid-connect/auth", configuration.getAuthorizationEndpoint()); assertEquals(baseFrontendUri + "/protocol/openid-connect/auth", configuration.getAuthorizationEndpoint());

View file

@ -1,105 +0,0 @@
package org.keycloak.testsuite.url;
import org.jboss.arquillian.container.test.api.ContainerController;
import org.jboss.arquillian.test.api.ArquillianResource;
import org.jboss.logging.Logger;
import org.keycloak.testsuite.AbstractKeycloakTest;
import org.keycloak.testsuite.arquillian.containers.AbstractQuarkusDeployableContainer;
import org.keycloak.testsuite.util.OAuthClient;
import java.net.URI;
import java.util.ArrayList;
import java.util.List;
public abstract class AbstractHostnameTest extends AbstractKeycloakTest {
private static final Logger LOGGER = Logger.getLogger(AbstractHostnameTest.class);
@ArquillianResource
protected ContainerController controller;
void reset() throws Exception {
LOGGER.info("Reset hostname config to default");
if (suiteContext.getAuthServerInfo().isUndertow()) {
controller.stop(suiteContext.getAuthServerInfo().getQualifier());
removeProperties("keycloak.hostname.provider",
"keycloak.frontendUrl",
"keycloak.adminUrl",
"keycloak.hostname.default.forceBackendUrlToFrontendUrl",
"keycloak.hostname.fixed.hostname",
"keycloak.hostname.fixed.httpPort",
"keycloak.hostname.fixed.httpsPort",
"keycloak.hostname.fixed.alwaysHttps");
controller.start(suiteContext.getAuthServerInfo().getQualifier());
} else if (suiteContext.getAuthServerInfo().isQuarkus()) {
AbstractQuarkusDeployableContainer container = (AbstractQuarkusDeployableContainer)suiteContext.getAuthServerInfo().getArquillianContainer().getDeployableContainer();
container.resetConfiguration();
configureDefault(OAuthClient.AUTH_SERVER_ROOT, false, null);
container.restartServer();
} else {
throw new RuntimeException("Don't know how to config");
}
reconnectAdminClient();
}
void configureDefault(String frontendUrl, boolean forceBackendUrlToFrontendUrl, String adminUrl) throws Exception {
LOGGER.infov("Configuring default hostname provider: frontendUrl={0}, forceBackendUrlToFrontendUrl={1}, adminUrl={3}", frontendUrl, forceBackendUrlToFrontendUrl, adminUrl);
if (suiteContext.getAuthServerInfo().isUndertow()) {
controller.stop(suiteContext.getAuthServerInfo().getQualifier());
System.setProperty("keycloak.hostname.provider", "default");
System.setProperty("keycloak.frontendUrl", frontendUrl);
if (adminUrl != null){
System.setProperty("keycloak.adminUrl", adminUrl);
}
System.setProperty("keycloak.hostname.default.forceBackendUrlToFrontendUrl", String.valueOf(forceBackendUrlToFrontendUrl));
controller.start(suiteContext.getAuthServerInfo().getQualifier());
} else if (suiteContext.getAuthServerInfo().isQuarkus()) {
controller.stop(suiteContext.getAuthServerInfo().getQualifier());
AbstractQuarkusDeployableContainer container = (AbstractQuarkusDeployableContainer)suiteContext.getAuthServerInfo().getArquillianContainer().getDeployableContainer();
List<String> additionalArgs = new ArrayList<>();
URI frontendUri = URI.create(frontendUrl);
// enable proxy so that we can check headers are taken into account when building urls
additionalArgs.add("--proxy=reencrypt");
additionalArgs.add("--hostname=" + frontendUri.getHost());
additionalArgs.add("--hostname-path=" + frontendUri.getPath());
if ("https".equals(frontendUri.getScheme())) {
additionalArgs.add("--hostname-strict-https=true");
}
additionalArgs.add("--hostname-strict-backchannel="+ forceBackendUrlToFrontendUrl);
container.setAdditionalBuildArgs(additionalArgs);
controller.start(suiteContext.getAuthServerInfo().getQualifier());
} else {
throw new RuntimeException("Don't know how to config");
}
reconnectAdminClient();
}
void configureFixed(String hostname, int httpPort, int httpsPort, boolean alwaysHttps) throws Exception {
if (suiteContext.getAuthServerInfo().isUndertow()) {
controller.stop(suiteContext.getAuthServerInfo().getQualifier());
System.setProperty("keycloak.hostname.provider", "fixed");
System.setProperty("keycloak.hostname.fixed.hostname", hostname);
System.setProperty("keycloak.hostname.fixed.httpPort", String.valueOf(httpPort));
System.setProperty("keycloak.hostname.fixed.httpsPort", String.valueOf(httpsPort));
System.setProperty("keycloak.hostname.fixed.alwaysHttps", String.valueOf(alwaysHttps));
controller.start(suiteContext.getAuthServerInfo().getQualifier());
} else {
throw new RuntimeException("Don't know how to config");
}
reconnectAdminClient();
}
private void removeProperties(String... keys) {
for (String k : keys) {
System.getProperties().remove(k);
}
}
}

View file

@ -1,350 +0,0 @@
package org.keycloak.testsuite.url;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.jboss.arquillian.container.test.api.ContainerController;
import org.jboss.arquillian.test.api.ArquillianResource;
import org.junit.Test;
import org.keycloak.admin.client.Keycloak;
import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.broker.provider.util.SimpleHttp;
import org.keycloak.client.registration.Auth;
import org.keycloak.client.registration.ClientRegistration;
import org.keycloak.client.registration.ClientRegistrationException;
import org.keycloak.common.util.UriUtils;
import org.keycloak.jose.jws.JWSInput;
import org.keycloak.jose.jws.JWSInputException;
import org.keycloak.models.BrowserSecurityHeaders;
import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.representations.idm.ClientInitialAccessCreatePresentation;
import org.keycloak.representations.idm.ClientInitialAccessPresentation;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude;
import org.keycloak.testsuite.broker.util.SimpleHttpDefault;
import org.keycloak.testsuite.util.AdminClientUtil;
import org.keycloak.testsuite.util.ClientBuilder;
import org.keycloak.testsuite.util.OAuthClient;
import org.keycloak.testsuite.util.RealmBuilder;
import org.keycloak.testsuite.util.UserBuilder;
import jakarta.ws.rs.core.UriBuilder;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude.AuthServer.QUARKUS;
import static org.keycloak.testsuite.util.OAuthClient.AUTH_SERVER_ROOT;
import static org.keycloak.testsuite.util.ServerURLs.getAuthServerContextRoot;
@AuthServerContainerExclude({QUARKUS})
public class DefaultHostnameTest extends AbstractHostnameTest {
@ArquillianResource
protected ContainerController controller;
private String expectedBackendUrl;
private String globalFrontEndUrl = "https://keycloak.127.0.0.1.nip.io/custom";
private String realmFrontEndUrl = "https://my-realm.127.0.0.1.nip.io";
@Override
public void addTestRealms(List<RealmRepresentation> testRealms) {
RealmRepresentation test = RealmBuilder.create().name("test")
.client(ClientBuilder.create().name("direct-grant").clientId("direct-grant").enabled(true).secret("password").directAccessGrants())
.user(UserBuilder.create().username("test-user@localhost").password("password"))
.build();
testRealms.add(test);
RealmRepresentation customHostname = RealmBuilder.create().name("frontendUrl")
.client(ClientBuilder.create().name("direct-grant").clientId("direct-grant").enabled(true).secret("password").directAccessGrants())
.user(UserBuilder.create().username("test-user@localhost").password("password"))
.attribute("frontendUrl", realmFrontEndUrl)
.build();
testRealms.add(customHostname);
}
@Test
public void fixedFrontendUrl() throws Exception {
expectedBackendUrl = transformUrlIfQuarkusServer(AUTH_SERVER_ROOT);
oauth.clientId("direct-grant");
try (Keycloak testAdminClient = AdminClientUtil.createAdminClient(suiteContext.isAdapterCompatTesting(), getAuthServerContextRoot())) {
assertWellKnown("test", expectedBackendUrl);
configureDefault(globalFrontEndUrl, false, null);
assertWellKnown("test", globalFrontEndUrl);
assertTokenIssuer("test", globalFrontEndUrl);
assertInitialAccessTokenFromMasterRealm(testAdminClient,"test", globalFrontEndUrl);
assertBackendForcedToFrontendWithMatchingHostname("test", globalFrontEndUrl);
assertAdminPage("master", globalFrontEndUrl, transformUrlIfQuarkusServer(globalFrontEndUrl, true));
assertWellKnown("frontendUrl", realmFrontEndUrl);
assertTokenIssuer("frontendUrl", realmFrontEndUrl);
assertInitialAccessTokenFromMasterRealm(testAdminClient,"frontendUrl", realmFrontEndUrl);
assertBackendForcedToFrontendWithMatchingHostname("frontendUrl", realmFrontEndUrl);
assertAdminPage("frontendUrl", realmFrontEndUrl, transformUrlIfQuarkusServer(realmFrontEndUrl, true));
} finally {
reset();
}
}
// KEYCLOAK-12953
@Test
public void emptyRealmFrontendUrl() throws Exception {
expectedBackendUrl = transformUrlIfQuarkusServer(AUTH_SERVER_ROOT);
oauth.clientId("direct-grant");
RealmResource realmResource = realmsResouce().realm("frontendUrl");
RealmRepresentation rep = realmResource.toRepresentation();
try {
rep.getAttributes().put("frontendUrl", "");
realmResource.update(rep);
assertWellKnown("frontendUrl", transformUrlIfQuarkusServer(AUTH_SERVER_ROOT));
} finally {
rep.getAttributes().put("frontendUrl", realmFrontEndUrl);
realmResource.update(rep);
reset();
}
}
@Test
public void wrongProtocolRealmFrontendUrl() throws Exception {
expectedBackendUrl = transformUrlIfQuarkusServer(AUTH_SERVER_ROOT);
oauth.clientId("direct-grant");
RealmResource realmResource = realmsResouce().realm("frontendUrl");
RealmRepresentation rep = realmResource.toRepresentation();
try {
rep.getAttributes().put("frontendUrl", "wrong://example.com");
realmResource.update(rep);
assertWellKnown("frontendUrl", transformUrlIfQuarkusServer(AUTH_SERVER_ROOT));
} finally {
rep.getAttributes().put("frontendUrl", realmFrontEndUrl);
realmResource.update(rep);
reset();
}
}
@Test
public void fixedAdminUrl() throws Exception {
expectedBackendUrl = transformUrlIfQuarkusServer(AUTH_SERVER_ROOT);
String adminUrl = transformUrlIfQuarkusServer("https://admin.127.0.0.1.nip.io/custom-admin", true);
oauth.clientId("direct-grant");
try {
assertWellKnown("test", expectedBackendUrl);
configureDefault(globalFrontEndUrl, false, adminUrl);
assertWelcomePage(adminUrl);
assertAdminPage("master", globalFrontEndUrl, adminUrl);
assertAdminPage("frontendUrl", realmFrontEndUrl, adminUrl);
} finally {
reset();
}
}
@Test
public void forceBackendUrlToFrontendUrl() throws Exception {
expectedBackendUrl = transformUrlIfQuarkusServer(AUTH_SERVER_ROOT);
oauth.clientId("direct-grant");
try (Keycloak testAdminClient = AdminClientUtil.createAdminClient(suiteContext.isAdapterCompatTesting(), getAuthServerContextRoot())) {
assertWellKnown("test", expectedBackendUrl);
configureDefault(globalFrontEndUrl, true, null);
expectedBackendUrl = globalFrontEndUrl;
assertWellKnown("test", globalFrontEndUrl);
assertTokenIssuer("test", globalFrontEndUrl);
assertInitialAccessTokenFromMasterRealm(testAdminClient,"test", globalFrontEndUrl);
expectedBackendUrl = realmFrontEndUrl;
assertWellKnown("frontendUrl", realmFrontEndUrl);
assertTokenIssuer("frontendUrl", realmFrontEndUrl);
assertInitialAccessTokenFromMasterRealm(testAdminClient,"frontendUrl", realmFrontEndUrl);
} finally {
reset();
}
}
private void assertInitialAccessTokenFromMasterRealm(Keycloak testAdminClient, String realm, String expectedBaseUrl) throws JWSInputException, ClientRegistrationException {
ClientInitialAccessCreatePresentation rep = new ClientInitialAccessCreatePresentation();
rep.setCount(1);
rep.setExpiration(10000);
ClientInitialAccessPresentation initialAccess = testAdminClient.realm(realm).clientInitialAccess().create(rep);
JsonWebToken token = new JWSInput(initialAccess.getToken()).readJsonContent(JsonWebToken.class);
assertEquals(expectedBaseUrl + "/realms/" + realm, token.getIssuer());
ClientRegistration clientReg = ClientRegistration.create().url(AUTH_SERVER_ROOT, realm).build();
clientReg.auth(Auth.token(initialAccess.getToken()));
ClientRepresentation client = new ClientRepresentation();
client.setEnabled(true);
ClientRepresentation response = clientReg.create(client);
String registrationAccessToken = response.getRegistrationAccessToken();
JsonWebToken registrationToken = new JWSInput(registrationAccessToken).readJsonContent(JsonWebToken.class);
assertEquals(expectedBaseUrl + "/realms/" + realm, registrationToken.getIssuer());
}
private void assertTokenIssuer(String realm, String expectedBaseUrl) throws Exception {
oauth.realm(realm);
oauth.requestHeaders(createRequestHeaders(expectedBaseUrl));
OAuthClient.AccessTokenResponse tokenResponse = oauth.doGrantAccessTokenRequest("password", "test-user@localhost", "password");
AccessToken token = new JWSInput(tokenResponse.getAccessToken()).readJsonContent(AccessToken.class);
assertEquals(expectedBaseUrl + "/realms/" + realm, token.getIssuer());
String introspection = oauth.introspectAccessTokenWithClientCredential(oauth.getClientId(), "password", tokenResponse.getAccessToken());
ObjectMapper objectMapper = new ObjectMapper();
JsonNode introspectionNode = objectMapper.readTree(introspection);
assertTrue(introspectionNode.get("active").asBoolean());
assertEquals(expectedBaseUrl + "/realms/" + realm, introspectionNode.get("iss").asText());
}
private void assertWellKnown(String realm, String expectedFrontendUrl) {
OIDCConfigurationRepresentation config = oauth.requestHeaders(createRequestHeaders(expectedFrontendUrl)).doWellKnownRequest(realm);
assertEquals(expectedFrontendUrl + "/realms/" + realm, config.getIssuer());
assertEquals(expectedFrontendUrl + "/realms/" + realm + "/protocol/openid-connect/auth", config.getAuthorizationEndpoint());
assertEquals(expectedBackendUrl + "/realms/" + realm + "/protocol/openid-connect/token", config.getTokenEndpoint());
assertEquals(expectedBackendUrl + "/realms/" + realm + "/protocol/openid-connect/userinfo", config.getUserinfoEndpoint());
assertEquals(expectedFrontendUrl + "/realms/" + realm + "/protocol/openid-connect/logout", config.getLogoutEndpoint());
assertEquals(expectedBackendUrl + "/realms/" + realm + "/protocol/openid-connect/certs", config.getJwksUri());
assertEquals(expectedFrontendUrl + "/realms/" + realm + "/protocol/openid-connect/login-status-iframe.html", config.getCheckSessionIframe());
assertEquals(expectedBackendUrl + "/realms/" + realm + "/clients-registrations/openid-connect", config.getRegistrationEndpoint());
}
private Map<String, String> createRequestHeaders(String expectedFrontendUrl) {
Map<String, String> headers = new HashMap<>();
// for quarkus so that we resolve ports based on proxy headers
URI uri = URI.create(expectedFrontendUrl);
headers.put("X-Forwarded-Port", String.valueOf(uri.getPort()));
return headers;
}
// Test backend is forced to frontend if the request hostname matches the frontend
private void assertBackendForcedToFrontendWithMatchingHostname(String realm, String expectedFrontendUrl) throws URISyntaxException {
String host = new URI(expectedFrontendUrl).getHost();
// Scheme and port doesn't matter as we force based on hostname only, so using http and bind port as we can't make requests on configured frontend URL since reverse proxy is not available
oauth.baseUrl("http://" + host + ":" + System.getProperty("auth.server.http.port") + "/auth");
OIDCConfigurationRepresentation config = oauth.requestHeaders(createRequestHeaders(expectedFrontendUrl)).doWellKnownRequest(realm);
assertEquals(expectedFrontendUrl + "/realms/" + realm, config.getIssuer());
assertEquals(expectedFrontendUrl + "/realms/" + realm + "/protocol/openid-connect/auth", config.getAuthorizationEndpoint());
assertEquals(expectedFrontendUrl + "/realms/" + realm + "/protocol/openid-connect/token", config.getTokenEndpoint());
assertEquals(expectedFrontendUrl + "/realms/" + realm + "/protocol/openid-connect/userinfo", config.getUserinfoEndpoint());
assertEquals(expectedFrontendUrl + "/realms/" + realm + "/protocol/openid-connect/logout", config.getLogoutEndpoint());
assertEquals(expectedFrontendUrl + "/realms/" + realm + "/protocol/openid-connect/certs", config.getJwksUri());
assertEquals(expectedFrontendUrl + "/realms/" + realm + "/protocol/openid-connect/login-status-iframe.html", config.getCheckSessionIframe());
assertEquals(expectedFrontendUrl + "/realms/" + realm + "/clients-registrations/openid-connect", config.getRegistrationEndpoint());
oauth.baseUrl(AUTH_SERVER_ROOT);
}
private void assertWelcomePage(String expectedAdminUrl) throws IOException {
try (CloseableHttpClient client = HttpClientBuilder.create().build()) {
SimpleHttp get = SimpleHttpDefault.doGet(AUTH_SERVER_ROOT + "/", client);
for (Map.Entry<String, String> entry : createRequestHeaders(expectedAdminUrl).entrySet()) {
get.header(entry.getKey(), entry.getValue());
}
String welcomePage = get.asString();
assertTrue(welcomePage.contains("<a href=\"" + expectedAdminUrl + "/admin/\">"));
}
}
private void assertOldAdminPageJsPathSetCorrectly(String realm, String expectedAdminUrl) throws IOException {
try (CloseableHttpClient client = HttpClientBuilder.create().build()) {
SimpleHttp get = SimpleHttpDefault.doGet(AUTH_SERVER_ROOT + "/admin/" + realm + "/console/", client);
for (Map.Entry<String, String> entry : createRequestHeaders(expectedAdminUrl).entrySet()) {
get.header(entry.getKey(), entry.getValue());
}
SimpleHttp.Response response = get.asResponse();
String indexPage = response.asString();
assertTrue(indexPage.contains("/custom/js/"));
}
}
private void assertAdminPage(String realm, String expectedFrontendUrl, String expectedAdminUrl) throws IOException, URISyntaxException {
try (CloseableHttpClient client = HttpClientBuilder.create().build()) {
SimpleHttp get = SimpleHttpDefault.doGet(AUTH_SERVER_ROOT + "/admin/" + realm + "/console/", client);
for (Map.Entry<String, String> entry : createRequestHeaders(expectedAdminUrl).entrySet()) {
get.header(entry.getKey(), entry.getValue());
}
SimpleHttp.Response response = get.asResponse();
String indexPage = response.asString();
assertTrue(indexPage.contains("\"authServerUrl\": \"" + expectedFrontendUrl +"\""));
assertTrue(indexPage.contains("\"authUrl\": \"" + expectedAdminUrl +"\""));
assertTrue(indexPage.contains("\"consoleBaseUrl\": \"" + new URI(expectedAdminUrl).getPath() +"/admin/" + realm + "/console/\""));
assertTrue(indexPage.contains("\"resourceUrl\": \"" + new URI(expectedAdminUrl).getPath() +"/resources/"));
String cspHeader = response.getFirstHeader(BrowserSecurityHeaders.CONTENT_SECURITY_POLICY.getHeaderName());
if (expectedFrontendUrl.equalsIgnoreCase(expectedAdminUrl)) {
assertEquals("frame-src 'self'; frame-ancestors 'self'; object-src 'none';", cspHeader);
} else {
assertEquals("frame-src " + UriUtils.getOrigin(expectedFrontendUrl) + "; frame-ancestors 'self'; object-src 'none';", cspHeader);
}
}
}
public String transformUrlIfQuarkusServer(String expectedUrl) {
return transformUrlIfQuarkusServer(expectedUrl, false);
}
public String transformUrlIfQuarkusServer(String expectedUrl, boolean adminUrl) {
if (suiteContext.getAuthServerInfo().isQuarkus()) {
// for quarkus, when proxy is enabled we always default to the default https and http ports.
UriBuilder uriBuilder = UriBuilder.fromUri(expectedUrl).port(-1);
if (adminUrl) {
// for quarkus, the path is set from the request. As we are not running behind a proxy, that means defaults to /auth.
uriBuilder.replacePath("/auth");
}
return uriBuilder.build().toString();
}
return expectedUrl;
}
}

View file

@ -1,278 +0,0 @@
package org.keycloak.testsuite.url;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.Test;
import org.keycloak.admin.client.Keycloak;
import org.keycloak.admin.client.resource.ClientResource;
import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.admin.client.resource.UserResource;
import org.keycloak.client.registration.Auth;
import org.keycloak.client.registration.ClientRegistration;
import org.keycloak.client.registration.ClientRegistrationException;
import org.keycloak.dom.saml.v2.metadata.EndpointType;
import org.keycloak.dom.saml.v2.metadata.EntityDescriptorType;
import org.keycloak.dom.saml.v2.metadata.IDPSSODescriptorType;
import org.keycloak.dom.saml.v2.protocol.ResponseType;
import org.keycloak.jose.jws.JWSInput;
import org.keycloak.jose.jws.JWSInputException;
import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation;
import org.keycloak.protocol.saml.SamlConfigAttributes;
import org.keycloak.protocol.saml.SamlProtocol;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.representations.idm.ClientInitialAccessCreatePresentation;
import org.keycloak.representations.idm.ClientInitialAccessPresentation;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.saml.common.constants.GeneralConstants;
import org.keycloak.saml.common.constants.JBossSAMLURIConstants;
import org.keycloak.saml.processing.core.parsers.saml.SAMLParser;
import org.keycloak.saml.processing.core.saml.v2.common.SAMLDocumentHolder;
import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude;
import org.keycloak.testsuite.updaters.Creator;
import org.keycloak.testsuite.util.AdminClientUtil;
import org.keycloak.testsuite.util.ClientBuilder;
import org.keycloak.testsuite.util.OAuthClient;
import org.keycloak.testsuite.util.RealmBuilder;
import org.keycloak.testsuite.util.SamlClient.Binding;
import org.keycloak.testsuite.util.SamlClientBuilder;
import org.keycloak.testsuite.util.UserBuilder;
import java.io.ByteArrayInputStream;
import java.net.URI;
import java.util.List;
import java.util.stream.Collectors;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.util.EntityUtils;
import org.hamcrest.Matchers;
import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.instanceOf;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.notNullValue;
import static org.junit.Assert.assertEquals;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.Assert.assertTrue;
import static org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude.AuthServer.QUARKUS;
import static org.keycloak.testsuite.util.ServerURLs.AUTH_SERVER_PORT;
import static org.keycloak.testsuite.util.ServerURLs.AUTH_SERVER_SCHEME;
import static org.keycloak.testsuite.util.ServerURLs.getAuthServerContextRoot;
@AuthServerContainerExclude(value = {QUARKUS},
details = "Quarkus supports its own hostname provider implementation similar to the default hostname provider")
public class FixedHostnameTest extends AbstractHostnameTest {
public static final String SAML_CLIENT_ID = "http://whatever.hostname:8280/app/";
private String authServerUrl;
@Override
public void addTestRealms(List<RealmRepresentation> testRealms) {
RealmRepresentation test = RealmBuilder.create().name("test")
.client(ClientBuilder.create().name("direct-grant").clientId("direct-grant").enabled(true).secret("password").directAccessGrants())
.user(UserBuilder.create().username("test-user@localhost").password("password"))
.build();
testRealms.add(test);
RealmRepresentation customHostname = RealmBuilder.create().name("hostname")
.client(ClientBuilder.create().name("direct-grant").clientId("direct-grant").enabled(true).secret("password").directAccessGrants())
.user(UserBuilder.create().username("test-user@localhost").password("password"))
.attribute("hostname", "custom-domain.127.0.0.1.nip.io")
.build();
testRealms.add(customHostname);
}
@Test
public void fixedHostname() throws Exception {
authServerUrl = oauth.AUTH_SERVER_ROOT;
oauth.baseUrl(authServerUrl);
oauth.clientId("direct-grant");
try (Keycloak testAdminClient = AdminClientUtil.createAdminClient(suiteContext.isAdapterCompatTesting(), getAuthServerContextRoot())) {
assertWellKnown("test", AUTH_SERVER_SCHEME + "://localhost:" + AUTH_SERVER_PORT);
assertSamlIdPDescriptor("test", AUTH_SERVER_SCHEME + "://localhost:" + AUTH_SERVER_PORT);
configureFixed("keycloak.127.0.0.1.nip.io", -1, -1, false);
assertWellKnown("test", AUTH_SERVER_SCHEME + "://keycloak.127.0.0.1.nip.io:" + AUTH_SERVER_PORT);
assertSamlIdPDescriptor("test", AUTH_SERVER_SCHEME + "://keycloak.127.0.0.1.nip.io:" + AUTH_SERVER_PORT);
assertWellKnown("hostname", AUTH_SERVER_SCHEME + "://custom-domain.127.0.0.1.nip.io:" + AUTH_SERVER_PORT);
assertSamlIdPDescriptor("hostname", AUTH_SERVER_SCHEME + "://custom-domain.127.0.0.1.nip.io:" + AUTH_SERVER_PORT);
assertTokenIssuer("test", AUTH_SERVER_SCHEME + "://keycloak.127.0.0.1.nip.io:" + AUTH_SERVER_PORT);
assertTokenIssuer("hostname", AUTH_SERVER_SCHEME + "://custom-domain.127.0.0.1.nip.io:" + AUTH_SERVER_PORT);
assertInitialAccessTokenFromMasterRealm(testAdminClient,"test", AUTH_SERVER_SCHEME + "://keycloak.127.0.0.1.nip.io:" + AUTH_SERVER_PORT);
assertSamlLogin(testAdminClient,"test", AUTH_SERVER_SCHEME + "://keycloak.127.0.0.1.nip.io:" + AUTH_SERVER_PORT);
assertInitialAccessTokenFromMasterRealm(testAdminClient,"hostname", AUTH_SERVER_SCHEME + "://custom-domain.127.0.0.1.nip.io:" + AUTH_SERVER_PORT);
assertSamlLogin(testAdminClient,"hostname", AUTH_SERVER_SCHEME + "://custom-domain.127.0.0.1.nip.io:" + AUTH_SERVER_PORT);
} finally {
reset();
}
}
@Test
public void fixedHttpPort() throws Exception {
// Make sure request are always sent with http
authServerUrl = "http://localhost:8180/auth";
oauth.baseUrl(authServerUrl);
oauth.clientId("direct-grant");
try (Keycloak testAdminClient = AdminClientUtil.createAdminClient(suiteContext.isAdapterCompatTesting(), "http://localhost:8180")) {
assertWellKnown("test", "http://localhost:8180");
assertSamlIdPDescriptor("test", "http://localhost:8180");
configureFixed("keycloak.127.0.0.1.nip.io", 80, -1, false);
assertWellKnown("test", "http://keycloak.127.0.0.1.nip.io");
assertSamlIdPDescriptor("test", "http://keycloak.127.0.0.1.nip.io");
assertWellKnown("hostname", "http://custom-domain.127.0.0.1.nip.io");
assertSamlIdPDescriptor("hostname", "http://custom-domain.127.0.0.1.nip.io");
assertTokenIssuer("test", "http://keycloak.127.0.0.1.nip.io");
assertTokenIssuer("hostname", "http://custom-domain.127.0.0.1.nip.io");
assertInitialAccessTokenFromMasterRealm(testAdminClient,"test", "http://keycloak.127.0.0.1.nip.io");
assertSamlLogin(testAdminClient,"test", "http://keycloak.127.0.0.1.nip.io");
assertInitialAccessTokenFromMasterRealm(testAdminClient,"hostname", "http://custom-domain.127.0.0.1.nip.io");
assertSamlLogin(testAdminClient,"hostname", "http://custom-domain.127.0.0.1.nip.io");
} finally {
reset();
}
}
@Test
public void fixedHostnameAlwaysHttpsHttpsPort() throws Exception {
// Make sure request are always sent with http
authServerUrl = "http://localhost:8180/auth";
oauth.baseUrl(authServerUrl);
oauth.clientId("direct-grant");
try (Keycloak testAdminClient = AdminClientUtil.createAdminClient(suiteContext.isAdapterCompatTesting(), "http://localhost:8180")) {
assertWellKnown("test", "http://localhost:8180");
assertSamlIdPDescriptor("test", "http://localhost:8180");
configureFixed("keycloak.127.0.0.1.nip.io", -1, 443, true);
assertWellKnown("test", "https://keycloak.127.0.0.1.nip.io");
assertSamlIdPDescriptor("test", "https://keycloak.127.0.0.1.nip.io");
assertWellKnown("hostname", "https://custom-domain.127.0.0.1.nip.io");
assertSamlIdPDescriptor("hostname", "https://custom-domain.127.0.0.1.nip.io");
assertTokenIssuer("test", "https://keycloak.127.0.0.1.nip.io");
assertTokenIssuer("hostname", "https://custom-domain.127.0.0.1.nip.io");
assertInitialAccessTokenFromMasterRealm(testAdminClient, "test", "https://keycloak.127.0.0.1.nip.io");
assertSamlLogin(testAdminClient, "test", "https://keycloak.127.0.0.1.nip.io");
assertInitialAccessTokenFromMasterRealm(testAdminClient, "hostname", "https://custom-domain.127.0.0.1.nip.io");
assertSamlLogin(testAdminClient, "hostname", "https://custom-domain.127.0.0.1.nip.io");
} finally {
reset();
}
}
private void assertInitialAccessTokenFromMasterRealm(Keycloak testAdminClient, String realm, String expectedBaseUrl) throws JWSInputException, ClientRegistrationException {
ClientInitialAccessCreatePresentation rep = new ClientInitialAccessCreatePresentation();
rep.setCount(1);
rep.setExpiration(10000);
ClientInitialAccessPresentation initialAccess = testAdminClient.realm(realm).clientInitialAccess().create(rep);
JsonWebToken token = new JWSInput(initialAccess.getToken()).readJsonContent(JsonWebToken.class);
assertEquals(expectedBaseUrl + "/auth/realms/" + realm, token.getIssuer());
ClientRegistration clientReg = ClientRegistration.create().url(authServerUrl, realm).build();
clientReg.auth(Auth.token(initialAccess.getToken()));
ClientRepresentation client = new ClientRepresentation();
client.setEnabled(true);
ClientRepresentation response = clientReg.create(client);
String registrationAccessToken = response.getRegistrationAccessToken();
JsonWebToken registrationToken = new JWSInput(registrationAccessToken).readJsonContent(JsonWebToken.class);
assertEquals(expectedBaseUrl + "/auth/realms/" + realm, registrationToken.getIssuer());
}
private void assertTokenIssuer(String realm, String expectedBaseUrl) throws Exception {
oauth.realm(realm);
OAuthClient.AccessTokenResponse tokenResponse = oauth.doGrantAccessTokenRequest("password", "test-user@localhost", "password");
AccessToken token = new JWSInput(tokenResponse.getAccessToken()).readJsonContent(AccessToken.class);
assertEquals(expectedBaseUrl + "/auth/realms/" + realm, token.getIssuer());
String introspection = oauth.introspectAccessTokenWithClientCredential(oauth.getClientId(), "password", tokenResponse.getAccessToken());
ObjectMapper objectMapper = new ObjectMapper();
JsonNode introspectionNode = objectMapper.readTree(introspection);
assertTrue(introspectionNode.get("active").asBoolean());
assertEquals(expectedBaseUrl + "/auth/realms/" + realm, introspectionNode.get("iss").asText());
}
private void assertWellKnown(String realm, String expectedBaseUrl) {
OIDCConfigurationRepresentation config = oauth.doWellKnownRequest(realm);
assertEquals(expectedBaseUrl + "/auth/realms/" + realm + "/protocol/openid-connect/token", config.getTokenEndpoint());
}
private void assertSamlIdPDescriptor(String realm, String expectedBaseUrl) throws Exception {
final String realmUrl = expectedBaseUrl + "/auth/realms/" + realm;
final String baseSamlEndpointUrl = realmUrl + "/protocol/saml";
String entityDescriptor = null;
try (
CloseableHttpClient client = HttpClientBuilder.create().build();
CloseableHttpResponse resp = client.execute(new HttpGet(baseSamlEndpointUrl + "/descriptor"))
) {
entityDescriptor = EntityUtils.toString(resp.getEntity(), GeneralConstants.SAML_CHARSET);
Object metadataO = SAMLParser.getInstance().parse(new ByteArrayInputStream(entityDescriptor.getBytes(GeneralConstants.SAML_CHARSET)));
assertThat(metadataO, instanceOf(EntityDescriptorType.class));
EntityDescriptorType ed = (EntityDescriptorType) metadataO;
assertThat(ed.getEntityID(), is(realmUrl));
IDPSSODescriptorType idpDescriptor = ed.getChoiceType().get(0).getDescriptors().get(0).getIdpDescriptor();
assertThat(idpDescriptor, notNullValue());
final List<String> locations = idpDescriptor.getSingleSignOnService().stream()
.map(EndpointType::getLocation)
.map(URI::toString)
.collect(Collectors.toList());
assertThat(locations, Matchers.everyItem(is(baseSamlEndpointUrl)));
} catch (Exception e) {
log.errorf("Caught exception while parsing SAML descriptor %s", entityDescriptor);
}
}
private void assertSamlLogin(Keycloak testAdminClient, String realm, String expectedBaseUrl) throws Exception {
final String realmUrl = expectedBaseUrl + "/auth/realms/" + realm;
final String baseSamlEndpointUrl = realmUrl + "/protocol/saml";
String entityDescriptor = null;
RealmResource realmResource = testAdminClient.realm(realm);
ClientRepresentation clientRep = ClientBuilder.create()
.protocol(SamlProtocol.LOGIN_PROTOCOL)
.clientId(SAML_CLIENT_ID)
.enabled(true)
.attribute(SamlConfigAttributes.SAML_CLIENT_SIGNATURE_ATTRIBUTE, "false")
.redirectUris("http://foo.bar/")
.build();
try (Creator<ClientResource> c = Creator.create(realmResource, clientRep);
Creator<UserResource> u = Creator.create(realmResource, UserBuilder.create().username("bicycle").password("race").enabled(true).build())) {
SAMLDocumentHolder samlResponse = new SamlClientBuilder()
.authnRequest(new URI(baseSamlEndpointUrl), SAML_CLIENT_ID, "http://foo.bar/", Binding.POST).build()
.login().user("bicycle", "race").build()
.getSamlResponse(Binding.POST);
assertThat(samlResponse.getSamlObject(), org.keycloak.testsuite.util.Matchers.isSamlResponse(JBossSAMLURIConstants.STATUS_SUCCESS));
ResponseType response = (ResponseType) samlResponse.getSamlObject();
assertThat(response.getAssertions(), hasSize(1));
assertThat(response.getAssertions().get(0).getAssertion().getIssuer().getValue(), is(realmUrl));
} catch (Exception e) {
log.errorf("Caught exception while parsing SAML descriptor %s", entityDescriptor);
}
}
}

View file

@ -0,0 +1,318 @@
/*
* Copyright 2024 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.testsuite.url;
import jakarta.ws.rs.core.Response;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.apache.http.conn.ssl.NoopHostnameVerifier;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.jboss.arquillian.container.spi.client.container.DeployableContainer;
import org.junit.After;
import org.junit.Assert;
import org.junit.Test;
import org.keycloak.broker.provider.util.SimpleHttp;
import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.testsuite.AbstractKeycloakTest;
import org.keycloak.testsuite.arquillian.containers.AbstractQuarkusDeployableContainer;
import org.keycloak.testsuite.arquillian.containers.RemoteContainer;
import org.keycloak.testsuite.broker.util.SimpleHttpDefault;
import org.keycloak.testsuite.util.RealmBuilder;
import java.util.ArrayList;
import java.util.List;
import static org.hamcrest.CoreMatchers.containsString;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.Assert.assertEquals;
import static org.keycloak.testsuite.util.OAuthClient.AUTH_SERVER_ROOT;
import static org.keycloak.testsuite.util.ServerURLs.AUTH_SERVER_PORT;
import static org.keycloak.testsuite.util.ServerURLs.AUTH_SERVER_SCHEME;
/**
* This is testing just the V2 implementation of Hostname SPI. It is NOT testing if the Hostname SPI as such is used correctly.
* It is NOT testing that correct URL types are used at various places in Keycloak.
*
* @author Vaclav Muzikar <vmuzikar@redhat.com>
*/
public class HostnameV2Test extends AbstractKeycloakTest {
private static final String realmFrontendName = "frontendUrlRealm";
private static final String realmFrontendUrl = "https://realmFrontend.127.0.0.1.nip.io:445";
@Override
public void addTestRealms(List<RealmRepresentation> testRealms) {
RealmRepresentation customHostname = RealmBuilder.create().name(realmFrontendName)
.attribute("frontendUrl", realmFrontendUrl)
.build();
testRealms.add(customHostname);
}
@Test
public void testFixedFrontendHostname() {
String hostname = "127.0.0.1.nip.io";
String dynamicUrl = getDynamicBaseUrl(hostname);
updateServerHostnameSettings(hostname, null, false, true);
testFrontendAndBackendUrls("master", dynamicUrl, dynamicUrl);
testAdminUrls("master", dynamicUrl, dynamicUrl);
}
@Test
public void testFixedFrontendHostnameUrl() {
String fixedUrl = "https://127.0.0.1.nip.io:444";
updateServerHostnameSettings(fixedUrl, null, false, true);
testFrontendAndBackendUrls("master", fixedUrl, fixedUrl);
testAdminUrls("master", fixedUrl, fixedUrl);
}
@Test
public void testFixedFrontendAndAdminHostnameUrl() {
String fixedFrontendUrl = "http://127.0.0.1.nip.io:444";
String fixedAdminUrl = "https://admin.127.0.0.1.nip.io:445";
updateServerHostnameSettings(fixedFrontendUrl, fixedAdminUrl, false, true);
testFrontendAndBackendUrls("master", fixedFrontendUrl, fixedFrontendUrl);
testAdminUrls("master", fixedFrontendUrl, fixedAdminUrl);
}
@Test
public void testFixedFrontendHostnameUrlWithDefaultPort() {
String fixedFrontendUrl = "https://127.0.0.1.nip.io";
String fixedAdminUrl = "https://admin.127.0.0.1.nip.io";
updateServerHostnameSettings("https://127.0.0.1.nip.io:443", "https://admin.127.0.0.1.nip.io:443", false, true);
testFrontendAndBackendUrls("master", fixedFrontendUrl, fixedFrontendUrl);
testAdminUrls("master", fixedFrontendUrl, fixedAdminUrl);
}
@Test
public void testDynamicBackend() {
String fixedUrl = "https://127.0.0.1.nip.io:444";
updateServerHostnameSettings(fixedUrl, null, true, true);
testFrontendAndBackendUrls("master", fixedUrl, AUTH_SERVER_ROOT);
testAdminUrls("master", fixedUrl, fixedUrl);
}
@Test
public void testDynamicEverything() {
updateServerHostnameSettings(null, null, false, false);
testFrontendAndBackendUrls("master", AUTH_SERVER_ROOT, AUTH_SERVER_ROOT);
testAdminUrls("master", AUTH_SERVER_ROOT, AUTH_SERVER_ROOT);
}
@Test
public void testRealmFrontendUrlWithOtherUrlsSet() {
String fixedFrontendUrl = "https://127.0.0.1.nip.io:444";
String fixedAdminUrl = "https://admin.127.0.0.1.nip.io:445";
updateServerHostnameSettings(fixedFrontendUrl, fixedAdminUrl, true, true);
testFrontendAndBackendUrls(realmFrontendName, realmFrontendUrl, AUTH_SERVER_ROOT);
testAdminUrls(realmFrontendName, realmFrontendUrl, fixedAdminUrl);
}
@Test
public void testAdminLocal() throws Exception {
updateServerHostnameSettings("https://127.0.0.1.nip.io:444", null, false, true);
// This is a hack. AdminLocal is used only on the Welcome Screen, nowhere else. Welcome Screen by default redirects to Admin Console if admin users exists.
// So we delete it and later recreate it.
String adminId = adminClient.realm("master").users().search("admin").get(0).getId();
try (Response ignore = adminClient.realm("master").users().delete(adminId)) {
suiteContext.setAdminPasswordUpdated(false);
try (CloseableHttpClient client = HttpClientBuilder.create().setSSLHostnameVerifier(NoopHostnameVerifier.INSTANCE).build()) {
// X-Forwarded-For is needed to trigger the correct message with a link, make Keycloak think we're not accessing it locally
SimpleHttp get = SimpleHttpDefault.doGet(getDynamicBaseUrl("127.0.0.1.nip.io"), client).header("X-Forwarded-For", "127.0.0.1");
String welcomePage = get.asString();
assertThat(welcomePage, containsString("<a href=\"" + getDynamicBaseUrl("localhost") + "/\">"));
}
}
finally {
updateMasterAdminPassword();
reconnectAdminClient();
}
}
@Test
public void testRealmFrontendUrl() {
updateServerHostnameSettings("127.0.0.1.nip.io", null, false, true);
testFrontendAndBackendUrls(realmFrontendName, realmFrontendUrl, realmFrontendUrl);
testAdminUrls(realmFrontendName, realmFrontendUrl, realmFrontendUrl);
}
@Test
public void testStrictMode() {
testStartupFailure("hostname is not configured; either configure hostname, or set hostname-strict to false",
null, null, null, true);
}
// @Test
// public void testStrictModeMustBeDisabledWhenHostnameIsSpecified() {
// testStartupFailure("hostname is configured, hostname-strict must be set to true",
// "127.0.0.1.nip.io", null, null, false);
// }
@Test
public void testInvalidHostnameUrl() {
testStartupFailure("Provided hostname is neither a plain hostname or a valid URL",
"htt://127.0.0.1.nip.io", null, null, true);
}
@Test
public void testInvalidAdminUrl() {
testStartupFailure("Provided hostname-admin is not a valid URL",
"127.0.0.1.nip.io", "htt://admin.127.0.0.1.nip.io", null, true);
}
@Test
public void testBackchannelDynamicRequiresHostname() {
testStartupFailure("hostname-backchannel-dynamic must be set to false when no hostname is provided",
null, null, true, false);
}
@Test
public void testBackchannelDynamicRequiresFullHostnameUrl() {
testStartupFailure("hostname-backchannel-dynamic must be set to false if hostname is not provided as full URL",
"127.0.0.1.nip.io", null, true, true);
}
private String getDynamicBaseUrl(String hostname) {
return AUTH_SERVER_SCHEME + "://" + hostname + ":" + AUTH_SERVER_PORT + "/auth";
}
private void testFrontendAndBackendUrls(String realm, String expectedFrontendUrl, String expectedBackendUrl) {
OIDCConfigurationRepresentation config = oauth.doWellKnownRequest(realm);
assertEquals(expectedFrontendUrl + "/realms/" + realm, config.getIssuer());
assertEquals(expectedFrontendUrl + "/realms/" + realm + "/protocol/openid-connect/auth", config.getAuthorizationEndpoint());
assertEquals(expectedBackendUrl + "/realms/" + realm + "/protocol/openid-connect/token", config.getTokenEndpoint());
assertEquals(expectedBackendUrl + "/realms/" + realm + "/protocol/openid-connect/userinfo", config.getUserinfoEndpoint());
}
private void testAdminUrls(String realm, String expectedFrontendUrl, String expectedAdminUrl) {
try (CloseableHttpClient client = HttpClientBuilder.create().build()) {
String adminIndexPage = SimpleHttpDefault.doGet(AUTH_SERVER_ROOT + "/admin/" + realm + "/console", client).asString();
assertThat(adminIndexPage, containsString("\"authServerUrl\": \"" + expectedFrontendUrl +"\""));
assertThat(adminIndexPage, containsString("\"authUrl\": \"" + expectedAdminUrl +"\""));
}
catch (Exception e) {
throw new RuntimeException(e);
}
}
private void testStartupFailure(String expectedError, String hostname, String hostnameAdmin, Boolean hostnameBackchannelDynamic, Boolean hostnameStrict) {
String errorLog = "";
DeployableContainer<?> container = suiteContext.getAuthServerInfo().getArquillianContainer().getDeployableContainer();
try {
updateServerHostnameSettings(hostname, hostnameAdmin, hostnameBackchannelDynamic, hostnameStrict);
Assert.fail("Server didn't fail");
}
catch (Exception e) {
if (container instanceof RemoteContainer) {
errorLog = ((RemoteContainer) container).getRemoteLog();
}
else {
errorLog = ExceptionUtils.getStackTrace(e);
}
}
// need to start the server back again to perform standard after test cleanup
resetHostnameSettings();
try {
container.stop(); // just to make sure all components are stopped (useful for Undertow)
container.start();
reconnectAdminClient();
}
catch (Exception e) {
throw new RuntimeException(e);
}
assertThat(errorLog, containsString(expectedError));
}
private void updateServerHostnameSettings(String hostname, String hostnameAdmin, Boolean hostnameBackchannelDynamic, Boolean hostnameStrict) {
try {
suiteContext.getAuthServerInfo().getArquillianContainer().getDeployableContainer().stop();
setHostnameOptions(hostname, hostnameAdmin, hostnameBackchannelDynamic, hostnameStrict);
suiteContext.getAuthServerInfo().getArquillianContainer().getDeployableContainer().start();
reconnectAdminClient();
}
catch (Exception e) {
throw new RuntimeException(e);
}
}
private void setHostnameOptions(String hostname, String hostnameAdmin, Boolean hostnameBackchannelDynamic, Boolean hostnameStrict) {
if (suiteContext.getAuthServerInfo().isQuarkus()) {
List<String> args = new ArrayList<>();
if (hostname != null) {
args.add("--hostname=" + hostname);
}
if (hostnameAdmin != null) {
args.add("--hostname-admin=" + hostnameAdmin);
}
if (hostnameBackchannelDynamic != null) {
args.add("--hostname-backchannel-dynamic=" + hostnameBackchannelDynamic);
}
if (hostnameStrict != null) {
args.add("--hostname-strict=" + hostnameStrict);
}
AbstractQuarkusDeployableContainer container = (AbstractQuarkusDeployableContainer) suiteContext.getAuthServerInfo().getArquillianContainer().getDeployableContainer();
container.setAdditionalBuildArgs(args);
}
else {
setConfigProperty("keycloak.hostname", hostname);
setConfigProperty("keycloak.hostname-admin", hostnameAdmin);
setConfigProperty("keycloak.hostname-backchannel-dynamic", hostnameBackchannelDynamic == null ? null : String.valueOf(hostnameBackchannelDynamic));
setConfigProperty("keycloak.hostname-strict", hostnameStrict == null ? null : String.valueOf(hostnameStrict));
}
}
@After
public void resetHostnameSettings() {
if (suiteContext.getAuthServerInfo().isQuarkus()) {
AbstractQuarkusDeployableContainer container = (AbstractQuarkusDeployableContainer) suiteContext.getAuthServerInfo().getArquillianContainer().getDeployableContainer();
container.resetConfiguration();
}
else {
setHostnameOptions(null, null, null, null);
setConfigProperty("keycloak.hostname.provider", null);
}
}
private static void setConfigProperty(String name, String value) {
if (value != null) {
System.setProperty(name, value);
}
else {
System.clearProperty(name);
}
}
}

View file

@ -1,19 +1,11 @@
{ {
"hostname": { "hostname": {
"provider": "${keycloak.hostname.provider:default}", "v2": {
"hostname": "${keycloak.hostname:}",
"fixed": { "hostname-admin": "${keycloak.hostname-admin:}",
"hostname": "${keycloak.hostname.fixed.hostname:localhost}", "hostname-backchannel-dynamic": "${keycloak.hostname-backchannel-dynamic:}",
"httpPort": "${keycloak.hostname.fixed.httpPort:-1}", "hostname-strict": "${keycloak.hostname-strict:}"
"httpsPort": "${keycloak.hostname.fixed.httpsPort:-1}",
"alwaysHttps": "${keycloak.hostname.fixed.alwaysHttps:false}"
},
"default": {
"frontendUrl": "${keycloak.frontendUrl:}",
"adminUrl": "${keycloak.adminUrl:}",
"forceBackendUrlToFrontendUrl": "${keycloak.hostname.default.forceBackendUrlToFrontendUrl:false}"
} }
}, },

View file

@ -1,15 +1,5 @@
{ {
"hostname": {
"provider": "${keycloak.hostname.provider:}",
"default": {
"frontendUrl": "${keycloak.frontendUrl:}",
"adminUrl": "${keycloak.adminUrl:}",
"forceBackendUrlToFrontendUrl": "${keycloak.hostname.default.forceBackendUrlToFrontendUrl:}"
}
},
"eventsStore": { "eventsStore": {
"provider": "${keycloak.eventsStore.provider:jpa}", "provider": "${keycloak.eventsStore.provider:jpa}",
"jpa": { "jpa": {