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:
parent
9651af4a1c
commit
e4987f10f5
58 changed files with 1429 additions and 1436 deletions
|
@ -106,8 +106,8 @@ public class Profile {
|
|||
|
||||
CLIENT_TYPES("Client Types", Type.EXPERIMENTAL),
|
||||
|
||||
HOSTNAME_V1("Hostname Options V1", Type.DEFAULT),
|
||||
//HOSTNAME_V2("Hostname Options V2", Type.DEFAULT, 2),
|
||||
HOSTNAME_V1("Hostname Options V1", Type.DEPRECATED, 1),
|
||||
HOSTNAME_V2("Hostname Options V2", Type.DEFAULT, 2),
|
||||
|
||||
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),
|
||||
|
@ -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);
|
||||
|
||||
|
@ -293,9 +293,9 @@ public class Profile {
|
|||
}
|
||||
}
|
||||
|
||||
private static ProfileConfigResolver.FeatureConfig getFeatureConfig(String unversionedFeature,
|
||||
private static ProfileConfigResolver.FeatureConfig getFeatureConfig(String feature,
|
||||
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))
|
||||
.findFirst()
|
||||
.orElse(ProfileConfigResolver.FeatureConfig.UNCONFIGURED);
|
||||
|
|
|
@ -46,6 +46,6 @@ public class PropertiesProfileConfigResolver implements ProfileConfigResolver {
|
|||
}
|
||||
|
||||
public static String getPropertyKey(String feature) {
|
||||
return "keycloak.profile.feature." + feature.replaceAll("-", "_");
|
||||
return "keycloak.profile.feature." + feature.replaceAll("[-:]", "_");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,7 +5,8 @@
|
|||
<@tmpl.guide
|
||||
title="Configuring the hostname"
|
||||
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
|
||||
|
||||
|
|
|
@ -1,58 +1,58 @@
|
|||
package org.keycloak.config;
|
||||
|
||||
public class HostnameOptions {
|
||||
public class HostnameV1Options {
|
||||
|
||||
public static final Option<String> HOSTNAME = new OptionBuilder<>("hostname", String.class)
|
||||
.category(OptionCategory.HOSTNAME)
|
||||
.category(OptionCategory.HOSTNAME_V1)
|
||||
.description("Hostname for the Keycloak server.")
|
||||
.build();
|
||||
|
||||
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.")
|
||||
.build();
|
||||
|
||||
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.")
|
||||
.build();
|
||||
|
||||
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")
|
||||
.build();
|
||||
|
||||
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.")
|
||||
.defaultValue(Boolean.TRUE)
|
||||
.build();
|
||||
|
||||
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.")
|
||||
.hidden()
|
||||
.defaultValue(Boolean.TRUE)
|
||||
.build();
|
||||
|
||||
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.")
|
||||
.build();
|
||||
|
||||
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.")
|
||||
.build();
|
||||
|
||||
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.")
|
||||
.defaultValue(-1)
|
||||
.build();
|
||||
|
||||
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")
|
||||
.defaultValue(Boolean.FALSE)
|
||||
.build();
|
|
@ -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();
|
||||
|
||||
}
|
|
@ -124,6 +124,10 @@ public class OptionBuilder<T> {
|
|||
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
|
@ -7,7 +7,8 @@ public enum OptionCategory {
|
|||
DATABASE("Database", 20, ConfigSupportLevel.SUPPORTED),
|
||||
TRANSACTION("Transaction",30, 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),
|
||||
HEALTH("Health", 70, ConfigSupportLevel.SUPPORTED),
|
||||
MANAGEMENT("Management", 75, ConfigSupportLevel.SUPPORTED),
|
||||
|
@ -48,7 +49,17 @@ public enum OptionCategory {
|
|||
return switch (supportLevel) {
|
||||
case EXPERIMENTAL -> heading + " (Experimental)";
|
||||
case PREVIEW -> heading + " (Preview)";
|
||||
case DEPRECATED -> heading + " (Deprecated)";
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -115,9 +115,6 @@ import org.keycloak.theme.FolderThemeProviderFactory;
|
|||
import org.keycloak.theme.JarThemeProviderFactory;
|
||||
import org.keycloak.theme.ThemeResourceSpi;
|
||||
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.util.JsonSerialization;
|
||||
import org.keycloak.vault.FilesKeystoreVaultProviderFactory;
|
||||
|
@ -178,9 +175,6 @@ class KeycloakProcessor {
|
|||
DefaultLiquibaseConnectionProvider.class,
|
||||
FolderThemeProviderFactory.class,
|
||||
LiquibaseJpaUpdaterProviderFactory.class,
|
||||
DefaultHostnameProviderFactory.class,
|
||||
FixedHostnameProviderFactory.class,
|
||||
RequestHostnameProviderFactory.class,
|
||||
FilesKeystoreVaultProviderFactory.class,
|
||||
FilesPlainTextVaultProviderFactory.class,
|
||||
BlacklistPasswordPolicyProviderFactory.class,
|
||||
|
|
|
@ -17,7 +17,9 @@
|
|||
|
||||
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.utils.StringUtil.removeSuffix;
|
||||
import static picocli.CommandLine.Help.Column.Overflow.SPAN;
|
||||
import static picocli.CommandLine.Help.Column.Overflow.WRAP;
|
||||
|
||||
|
@ -25,6 +27,8 @@ import java.util.ArrayList;
|
|||
import java.util.Comparator;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
|
||||
import org.keycloak.config.OptionCategory;
|
||||
import org.keycloak.quarkus.runtime.configuration.mappers.PropertyMapper;
|
||||
import org.keycloak.quarkus.runtime.configuration.mappers.PropertyMappers;
|
||||
import org.keycloak.utils.StringUtil;
|
||||
|
@ -158,10 +162,16 @@ public final class Help extends CommandLine.Help {
|
|||
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) {
|
||||
final var disabledMapper = PropertyMappers.getDisabledMapper(option.longestName());
|
||||
final var disabledMapper = PropertyMappers.getDisabledMapper(optionName);
|
||||
final var isDisabledMapper = disabledMapper.isPresent();
|
||||
|
||||
// Show disabled mappers, which do not have a description when they're enabled
|
||||
|
|
|
@ -17,24 +17,22 @@
|
|||
|
||||
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 picocli.CommandLine;
|
||||
import picocli.CommandLine.Help.Ansi.Text;
|
||||
import picocli.CommandLine.Help.ColorScheme;
|
||||
import picocli.CommandLine.Help.IParamLabelRenderer;
|
||||
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 {
|
||||
|
||||
private static final String OPTION_NAME_SEPARATOR = ", ";
|
||||
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
|
||||
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) {
|
||||
Text name = scheme.optionText(option.longestName());
|
||||
Text name = scheme.optionText(undecorateDuplicitOptionName(option.longestName()));
|
||||
String paramLabel = formatParamLabel(option);
|
||||
|
||||
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 + ">";
|
||||
}
|
||||
|
||||
public static String decorateDuplicitOptionName(String name) {
|
||||
return name + DUPLICIT_OPTION_SUFFIX;
|
||||
}
|
||||
|
||||
public static String undecorateDuplicitOptionName(String name) {
|
||||
return removeSuffix(name, DUPLICIT_OPTION_SUFFIX);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.isRebuildCheck;
|
||||
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.configuration.ConfigArgsConfigSource.parseConfigArgs;
|
||||
import static org.keycloak.quarkus.runtime.configuration.Configuration.OPTION_PART_SEPARATOR;
|
||||
|
@ -99,7 +100,6 @@ public final class Picocli {
|
|||
private static class IncludeOptions {
|
||||
boolean includeRuntime;
|
||||
boolean includeBuildTime;
|
||||
boolean includeDisabled;
|
||||
}
|
||||
|
||||
private Picocli() {
|
||||
|
@ -378,7 +378,7 @@ public final class Picocli {
|
|||
}
|
||||
|
||||
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 {
|
||||
DisabledMappersInterceptor.enable(disabledMappersInterceptorEnabled);
|
||||
|
@ -618,7 +618,6 @@ public final class Picocli {
|
|||
}
|
||||
result.includeRuntime = abstractCommand.includeRuntime();
|
||||
result.includeBuildTime = abstractCommand.includeBuildTime();
|
||||
result.includeDisabled = cliArgs.contains(HelpAllMixin.HELP_ALL_OPTION);
|
||||
|
||||
if (!result.includeBuildTime && !result.includeRuntime) {
|
||||
return result;
|
||||
|
@ -660,34 +659,19 @@ public final class Picocli {
|
|||
private static void addOptionsToCli(CommandLine commandLine, IncludeOptions includeOptions) {
|
||||
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) {
|
||||
mappers.putAll(PropertyMappers.getRuntimeMappers());
|
||||
|
||||
if (includeOptions.includeDisabled) {
|
||||
appendDisabledMappers(mappers, PropertyMappers.getDisabledRuntimeMappers());
|
||||
}
|
||||
}
|
||||
|
||||
if (includeOptions.includeBuildTime) {
|
||||
combinePropertyMappers(mappers, PropertyMappers.getBuildTimeMappers());
|
||||
|
||||
if (includeOptions.includeDisabled) {
|
||||
appendDisabledMappers(mappers, PropertyMappers.getDisabledBuildTimeMappers());
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
for (var entry : additionalMappers.entrySet()) {
|
||||
final List<PropertyMapper<?>> result = origMappers.getOrDefault(entry.getKey(), new ArrayList<>());
|
||||
|
@ -715,6 +699,14 @@ public final class Picocli {
|
|||
|
||||
for (PropertyMapper<?> mapper : mappersInCategory) {
|
||||
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();
|
||||
|
||||
if (description == null || cSpec.optionsMap().containsKey(name) || name.endsWith(OPTION_PART_SEPARATOR) || alreadyPresentArgs.contains(name)) {
|
||||
|
|
|
@ -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.Start;
|
||||
import org.keycloak.quarkus.runtime.configuration.KcUnmatchedArgumentException;
|
||||
import org.keycloak.quarkus.runtime.configuration.mappers.PropertyMapper;
|
||||
import org.keycloak.quarkus.runtime.configuration.mappers.PropertyMappers;
|
||||
|
||||
|
@ -72,6 +73,9 @@ public class ShortErrorMessageHandler implements IParameterExceptionHandler {
|
|||
}
|
||||
|
||||
writer.println(cmd.getColorScheme().errorText(errorMessage));
|
||||
if (!(ex instanceof KcUnmatchedArgumentException) && ex instanceof UnmatchedArgumentException) {
|
||||
ex = new KcUnmatchedArgumentException((UnmatchedArgumentException) ex);
|
||||
}
|
||||
UnmatchedArgumentException.printSuggestions(ex, writer);
|
||||
|
||||
CommandSpec spec = cmd.getCommandSpec();
|
||||
|
|
|
@ -52,7 +52,8 @@ public abstract class AbstractExportImportCommand extends AbstractStartCommand i
|
|||
return super.getOptionCategories().stream().filter(optionCategory ->
|
||||
optionCategory != OptionCategory.HTTP &&
|
||||
optionCategory != OptionCategory.PROXY &&
|
||||
optionCategory != OptionCategory.HOSTNAME &&
|
||||
optionCategory != OptionCategory.HOSTNAME_V1 &&
|
||||
optionCategory != OptionCategory.HOSTNAME_V2 &&
|
||||
optionCategory != OptionCategory.METRICS &&
|
||||
optionCategory != OptionCategory.VAULT &&
|
||||
optionCategory != OptionCategory.SECURITY &&
|
||||
|
|
|
@ -22,6 +22,8 @@ import picocli.CommandLine;
|
|||
|
||||
import java.util.List;
|
||||
|
||||
import static org.keycloak.quarkus.runtime.cli.OptionRenderer.DUPLICIT_OPTION_SUFFIX;
|
||||
|
||||
/**
|
||||
* Custom CommandLine.UnmatchedArgumentException with amended suggestions
|
||||
*/
|
||||
|
@ -31,9 +33,13 @@ public class KcUnmatchedArgumentException extends CommandLine.UnmatchedArgumentE
|
|||
super(commandLine, args);
|
||||
}
|
||||
|
||||
public KcUnmatchedArgumentException(CommandLine.UnmatchedArgumentException ex) {
|
||||
super(ex.getCommandLine(), ex.getUnmatched());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> getSuggestions() {
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
};
|
||||
}
|
||||
|
||||
}
|
|
@ -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]);
|
||||
}
|
||||
|
||||
}
|
|
@ -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]);
|
||||
}
|
||||
|
||||
}
|
|
@ -18,7 +18,6 @@ import org.keycloak.quarkus.runtime.configuration.DisabledMappersInterceptor;
|
|||
import org.keycloak.quarkus.runtime.configuration.PersistedConfigSource;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.EnumMap;
|
||||
|
@ -29,7 +28,6 @@ import java.util.Map;
|
|||
import java.util.Optional;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.Set;
|
||||
import java.util.function.BooleanSupplier;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
|
@ -48,7 +46,8 @@ public final class PropertyMappers {
|
|||
static {
|
||||
MAPPERS.addAll(CachingPropertyMappers.getClusteringPropertyMappers());
|
||||
MAPPERS.addAll(DatabasePropertyMappers.getDatabasePropertyMappers());
|
||||
MAPPERS.addAll(HostnamePropertyMappers.getHostnamePropertyMappers());
|
||||
MAPPERS.addAll(HostnameV2PropertyMappers.getHostnamePropertyMappers());
|
||||
MAPPERS.addAll(HostnameV1PropertyMappers.getHostnamePropertyMappers());
|
||||
MAPPERS.addAll(HttpPropertyMappers.getHttpPropertyMappers());
|
||||
MAPPERS.addAll(HealthPropertyMappers.getHealthPropertyMappers());
|
||||
MAPPERS.addAll(ConfigKeystorePropertyMappers.getConfigKeystorePropertyMappers());
|
||||
|
@ -145,33 +144,33 @@ public final class PropertyMappers {
|
|||
return property;
|
||||
}
|
||||
|
||||
private static PropertyMapper<?> getMapperOrDefault(String property, PropertyMapper<?> defaultMapper) {
|
||||
final var mappers = MAPPERS.getOrDefault(property, Collections.emptyList());
|
||||
private static PropertyMapper<?> getMapperOrDefault(String property, PropertyMapper<?> defaultMapper, OptionCategory category) {
|
||||
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()) {
|
||||
case 0 -> defaultMapper;
|
||||
case 1 -> mappers.get(0);
|
||||
default -> {
|
||||
var allowedMappers = filterDeniedCategories(mappers);
|
||||
|
||||
yield switch (allowedMappers.size()) {
|
||||
case 0 -> defaultMapper;
|
||||
case 1 -> allowedMappers.iterator().next();
|
||||
default -> {
|
||||
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) {
|
||||
return getMapperOrDefault(polishProperty(property), null);
|
||||
}
|
||||
|
||||
public static List<PropertyMapper<?>> getMappers(String property) {
|
||||
return MAPPERS.get(polishProperty(property));
|
||||
return getMapper(property, null);
|
||||
}
|
||||
|
||||
public static Set<PropertyMapper<?>> getMappers() {
|
||||
|
@ -179,7 +178,8 @@ public final class PropertyMappers {
|
|||
}
|
||||
|
||||
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) {
|
||||
|
@ -201,10 +201,6 @@ public final class PropertyMappers {
|
|||
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) {
|
||||
final var allowedCategories = Environment.getParsedCommand()
|
||||
.map(AbstractCommand::getOptionCategories)
|
||||
|
@ -222,15 +218,6 @@ public final class PropertyMappers {
|
|||
private final Map<String, PropertyMapper<?>> disabledBuildTimeMappers = 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) {
|
||||
for (PropertyMapper<?> mapper : mappers) {
|
||||
addMapper(mapper);
|
||||
|
|
|
@ -41,7 +41,7 @@ import org.keycloak.common.Profile;
|
|||
import org.keycloak.common.Profile.Feature;
|
||||
import org.keycloak.common.enums.SslRequired;
|
||||
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.Mode;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
|
@ -272,7 +272,7 @@ public final class DefaultHostnameProvider implements HostnameProvider, Hostname
|
|||
}
|
||||
|
||||
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)) {
|
||||
|
@ -325,7 +325,7 @@ public final class DefaultHostnameProvider implements HostnameProvider, Hostname
|
|||
}
|
||||
|
||||
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) {
|
||||
|
|
|
@ -44,4 +44,16 @@ public class ConstantsDebugHostname {
|
|||
"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"
|
||||
};
|
||||
|
||||
}
|
||||
|
|
|
@ -17,7 +17,17 @@
|
|||
package org.keycloak.quarkus.runtime.services.resources;
|
||||
|
||||
import io.quarkus.resteasy.reactive.server.EndpointDisabled;
|
||||
import jakarta.ws.rs.GET;
|
||||
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.RealmModel;
|
||||
import org.keycloak.quarkus.runtime.Environment;
|
||||
|
@ -29,16 +39,6 @@ import org.keycloak.theme.Theme;
|
|||
import org.keycloak.theme.freemarker.FreeMarkerProvider;
|
||||
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.net.URI;
|
||||
import java.util.HashMap;
|
||||
|
@ -62,7 +62,8 @@ public class DebugHostnameSettingsResource {
|
|||
public DebugHostnameSettingsResource() {
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
@ -95,6 +96,7 @@ public class DebugHostnameSettingsResource {
|
|||
|
||||
attributes.put("realm", realmModel.getName());
|
||||
attributes.put("realmUrl", realmModel.getAttribute("frontendUrl"));
|
||||
attributes.put("implVersion", Profile.isFeatureEnabled(Profile.Feature.HOSTNAME_V2) ? "V2" : "V1");
|
||||
|
||||
attributes.put("frontendTestUrl", frontendTestUrl);
|
||||
attributes.put("backendTestUrl", backendTestUrl);
|
||||
|
|
|
@ -70,6 +70,10 @@
|
|||
<td>${realmUrl}</td>
|
||||
</tr>
|
||||
</#if>
|
||||
<tr>
|
||||
<td>Hostname SPI implementation</td>
|
||||
<td>${implVersion}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th>Configuration property</th>
|
||||
|
|
|
@ -38,10 +38,10 @@ import java.util.function.Consumer;
|
|||
import static io.restassured.RestAssured.when;
|
||||
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"})
|
||||
@RawDistOnly(reason = "Containers are immutable")
|
||||
public class HostnameDistTest {
|
||||
public class HostnameV1DistTest {
|
||||
|
||||
@BeforeAll
|
||||
public static void onBeforeAll() {
|
44
quarkus/tests/integration/src/test/java/org/keycloak/it/cli/dist/HostnameV2DistTest.java
vendored
Normal file
44
quarkus/tests/integration/src/test/java/org/keycloak/it/cli/dist/HostnameV2DistTest.java
vendored
Normal 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();
|
||||
}
|
||||
}
|
|
@ -34,10 +34,10 @@ import static io.restassured.RestAssured.given;
|
|||
import static io.restassured.RestAssured.when;
|
||||
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"})
|
||||
@RawDistOnly(reason = "Containers are immutable")
|
||||
public class ProxyDistTest {
|
||||
public class ProxyHostnameV1DistTest {
|
||||
|
||||
@BeforeAll
|
||||
public static void onBeforeAll() {
|
138
quarkus/tests/integration/src/test/java/org/keycloak/it/cli/dist/ProxyHostnameV2DistTest.java
vendored
Normal file
138
quarkus/tests/integration/src/test/java/org/keycloak/it/cli/dist/ProxyHostnameV2DistTest.java
vendored
Normal 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());
|
||||
}
|
||||
}
|
|
@ -84,7 +84,7 @@ public class StartCommandDistTest {
|
|||
@Test
|
||||
@Launch({ "start", "--http-enabled=true" })
|
||||
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.");
|
||||
}
|
||||
|
||||
|
|
|
@ -120,34 +120,32 @@ Feature:
|
|||
--features-disabled <feature>
|
||||
Disables a set of one or more features. Possible values are: <...>.
|
||||
|
||||
Hostname:
|
||||
Hostname v2:
|
||||
|
||||
--hostname <hostname>
|
||||
Hostname for the Keycloak server.
|
||||
--hostname-admin <hostname>
|
||||
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.
|
||||
--hostname-admin-url <url>
|
||||
Set the base URL for accessing the administration console, including scheme,
|
||||
host, port and path
|
||||
--hostname <hostname|URL>
|
||||
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. Available only when hostname:v2 feature is enabled.
|
||||
--hostname-admin <URL>
|
||||
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. Available only when
|
||||
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>
|
||||
Toggle the hostname debug page that is accessible at
|
||||
/realms/master/hostname-debug Default: false.
|
||||
--hostname-path <path>
|
||||
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.
|
||||
Toggles the hostname debug page that is accessible at
|
||||
/realms/master/hostname-debug. Default: false. Available only when hostname:
|
||||
v2 feature is enabled.
|
||||
--hostname-strict <true|false>
|
||||
Disables dynamically resolving the hostname from request headers. Should
|
||||
always be set to true in production, unless proxy verifies the Host header.
|
||||
Default: true.
|
||||
--hostname-strict-backchannel <true|false>
|
||||
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.
|
||||
always be set to true in production, unless your reverse proxy overwrites
|
||||
the Host header. If enabled, the 'hostname' option needs to be specified.
|
||||
Default: true. Available only when hostname:v2 feature is enabled.
|
||||
|
||||
HTTP(S):
|
||||
|
||||
|
|
|
@ -123,34 +123,73 @@ Feature:
|
|||
--features-disabled <feature>
|
||||
Disables a set of one or more features. Possible values are: <...>.
|
||||
|
||||
Hostname:
|
||||
Hostname v2:
|
||||
|
||||
--hostname <hostname>
|
||||
Hostname for the Keycloak server.
|
||||
--hostname-admin <hostname>
|
||||
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.
|
||||
--hostname-admin-url <url>
|
||||
Set the base URL for accessing the administration console, including scheme,
|
||||
host, port and path
|
||||
--hostname <hostname|URL>
|
||||
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. Available only when hostname:v2 feature is enabled.
|
||||
--hostname-admin <URL>
|
||||
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. Available only when
|
||||
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>
|
||||
Toggle the hostname debug page that is accessible at
|
||||
/realms/master/hostname-debug Default: false.
|
||||
--hostname-path <path>
|
||||
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.
|
||||
Toggles the hostname debug page that is accessible at
|
||||
/realms/master/hostname-debug. Default: false. Available only when hostname:
|
||||
v2 feature is enabled.
|
||||
--hostname-strict <true|false>
|
||||
Disables dynamically resolving the hostname from request headers. Should
|
||||
always be set to true in production, unless proxy verifies the Host header.
|
||||
Default: true.
|
||||
always be set to true in production, unless your reverse proxy overwrites
|
||||
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>
|
||||
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.
|
||||
DEPRECATED. 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. Available only
|
||||
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):
|
||||
|
||||
|
|
|
@ -121,34 +121,32 @@ Feature:
|
|||
--features-disabled <feature>
|
||||
Disables a set of one or more features. Possible values are: <...>.
|
||||
|
||||
Hostname:
|
||||
Hostname v2:
|
||||
|
||||
--hostname <hostname>
|
||||
Hostname for the Keycloak server.
|
||||
--hostname-admin <hostname>
|
||||
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.
|
||||
--hostname-admin-url <url>
|
||||
Set the base URL for accessing the administration console, including scheme,
|
||||
host, port and path
|
||||
--hostname <hostname|URL>
|
||||
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. Available only when hostname:v2 feature is enabled.
|
||||
--hostname-admin <URL>
|
||||
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. Available only when
|
||||
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>
|
||||
Toggle the hostname debug page that is accessible at
|
||||
/realms/master/hostname-debug Default: false.
|
||||
--hostname-path <path>
|
||||
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.
|
||||
Toggles the hostname debug page that is accessible at
|
||||
/realms/master/hostname-debug. Default: false. Available only when hostname:
|
||||
v2 feature is enabled.
|
||||
--hostname-strict <true|false>
|
||||
Disables dynamically resolving the hostname from request headers. Should
|
||||
always be set to true in production, unless proxy verifies the Host header.
|
||||
Default: true.
|
||||
--hostname-strict-backchannel <true|false>
|
||||
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.
|
||||
always be set to true in production, unless your reverse proxy overwrites
|
||||
the Host header. If enabled, the 'hostname' option needs to be specified.
|
||||
Default: true. Available only when hostname:v2 feature is enabled.
|
||||
|
||||
HTTP(S):
|
||||
|
||||
|
|
|
@ -124,34 +124,73 @@ Feature:
|
|||
--features-disabled <feature>
|
||||
Disables a set of one or more features. Possible values are: <...>.
|
||||
|
||||
Hostname:
|
||||
Hostname v2:
|
||||
|
||||
--hostname <hostname>
|
||||
Hostname for the Keycloak server.
|
||||
--hostname-admin <hostname>
|
||||
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.
|
||||
--hostname-admin-url <url>
|
||||
Set the base URL for accessing the administration console, including scheme,
|
||||
host, port and path
|
||||
--hostname <hostname|URL>
|
||||
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. Available only when hostname:v2 feature is enabled.
|
||||
--hostname-admin <URL>
|
||||
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. Available only when
|
||||
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>
|
||||
Toggle the hostname debug page that is accessible at
|
||||
/realms/master/hostname-debug Default: false.
|
||||
--hostname-path <path>
|
||||
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.
|
||||
Toggles the hostname debug page that is accessible at
|
||||
/realms/master/hostname-debug. Default: false. Available only when hostname:
|
||||
v2 feature is enabled.
|
||||
--hostname-strict <true|false>
|
||||
Disables dynamically resolving the hostname from request headers. Should
|
||||
always be set to true in production, unless proxy verifies the Host header.
|
||||
Default: true.
|
||||
always be set to true in production, unless your reverse proxy overwrites
|
||||
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>
|
||||
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.
|
||||
DEPRECATED. 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. Available only
|
||||
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):
|
||||
|
||||
|
|
|
@ -94,34 +94,32 @@ Database:
|
|||
--db-username <username>
|
||||
The username of the database user.
|
||||
|
||||
Hostname:
|
||||
Hostname v2:
|
||||
|
||||
--hostname <hostname>
|
||||
Hostname for the Keycloak server.
|
||||
--hostname-admin <hostname>
|
||||
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.
|
||||
--hostname-admin-url <url>
|
||||
Set the base URL for accessing the administration console, including scheme,
|
||||
host, port and path
|
||||
--hostname <hostname|URL>
|
||||
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. Available only when hostname:v2 feature is enabled.
|
||||
--hostname-admin <URL>
|
||||
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. Available only when
|
||||
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>
|
||||
Toggle the hostname debug page that is accessible at
|
||||
/realms/master/hostname-debug Default: false.
|
||||
--hostname-path <path>
|
||||
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.
|
||||
Toggles the hostname debug page that is accessible at
|
||||
/realms/master/hostname-debug. Default: false. Available only when hostname:
|
||||
v2 feature is enabled.
|
||||
--hostname-strict <true|false>
|
||||
Disables dynamically resolving the hostname from request headers. Should
|
||||
always be set to true in production, unless proxy verifies the Host header.
|
||||
Default: true.
|
||||
--hostname-strict-backchannel <true|false>
|
||||
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.
|
||||
always be set to true in production, unless your reverse proxy overwrites
|
||||
the Host header. If enabled, the 'hostname' option needs to be specified.
|
||||
Default: true. Available only when hostname:v2 feature is enabled.
|
||||
|
||||
HTTP(S):
|
||||
|
||||
|
|
|
@ -97,34 +97,73 @@ Database:
|
|||
--db-username <username>
|
||||
The username of the database user.
|
||||
|
||||
Hostname:
|
||||
Hostname v2:
|
||||
|
||||
--hostname <hostname>
|
||||
Hostname for the Keycloak server.
|
||||
--hostname-admin <hostname>
|
||||
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.
|
||||
--hostname-admin-url <url>
|
||||
Set the base URL for accessing the administration console, including scheme,
|
||||
host, port and path
|
||||
--hostname <hostname|URL>
|
||||
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. Available only when hostname:v2 feature is enabled.
|
||||
--hostname-admin <URL>
|
||||
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. Available only when
|
||||
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>
|
||||
Toggle the hostname debug page that is accessible at
|
||||
/realms/master/hostname-debug Default: false.
|
||||
--hostname-path <path>
|
||||
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.
|
||||
Toggles the hostname debug page that is accessible at
|
||||
/realms/master/hostname-debug. Default: false. Available only when hostname:
|
||||
v2 feature is enabled.
|
||||
--hostname-strict <true|false>
|
||||
Disables dynamically resolving the hostname from request headers. Should
|
||||
always be set to true in production, unless proxy verifies the Host header.
|
||||
Default: true.
|
||||
always be set to true in production, unless your reverse proxy overwrites
|
||||
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>
|
||||
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.
|
||||
DEPRECATED. 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. Available only
|
||||
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):
|
||||
|
||||
|
|
|
@ -99,4 +99,12 @@ public class StringUtil {
|
|||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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";
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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";
|
||||
}
|
||||
|
||||
}
|
171
services/src/main/java/org/keycloak/url/HostnameV2Provider.java
Normal file
171
services/src/main/java/org/keycloak/url/HostnameV2Provider.java
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
|
@ -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";
|
||||
}
|
||||
|
||||
}
|
|
@ -1,3 +1 @@
|
|||
org.keycloak.url.DefaultHostnameProviderFactory
|
||||
org.keycloak.url.FixedHostnameProviderFactory
|
||||
org.keycloak.url.RequestHostnameProviderFactory
|
||||
org.keycloak.url.HostnameV2ProviderFactory
|
|
@ -8,7 +8,6 @@ http-enabled=true
|
|||
|
||||
# Disables strict hostname
|
||||
hostname-strict=false
|
||||
hostname-strict-https=false
|
||||
|
||||
# SSL
|
||||
https-key-store-file=${kc.home.dir}/conf/keycloak.jks
|
||||
|
@ -19,7 +18,7 @@ https-client-auth=request
|
|||
|
||||
# Proxy
|
||||
# Using any proxy setting which evaluates the forward proxy header
|
||||
proxy=reencrypt
|
||||
proxy-headers=xforwarded
|
||||
|
||||
# Hostname Provider
|
||||
spi-hostname-default-frontend-url = ${keycloak.frontendUrl:}
|
||||
|
|
|
@ -249,10 +249,17 @@ public class KeycloakOnUndertow implements DeployableContainer<KeycloakOnUnderto
|
|||
}
|
||||
|
||||
log.info("Stopping auth server.");
|
||||
if (sessionFactory != null) {
|
||||
sessionFactory.close();
|
||||
}
|
||||
if (undertow != null) {
|
||||
undertow.stop();
|
||||
}
|
||||
|
||||
sessionFactory = null;
|
||||
undertow = null;
|
||||
}
|
||||
|
||||
private boolean isRemoteMode() {
|
||||
//return true;
|
||||
return configuration.isRemoteMode();
|
||||
|
|
|
@ -284,7 +284,7 @@ public abstract class AbstractQuarkusDeployableContainer implements DeployableCo
|
|||
additionalBuildArgs = Collections.emptyList();
|
||||
}
|
||||
|
||||
protected void waitForReadiness() throws MalformedURLException, LifecycleException {
|
||||
protected void waitForReadiness() throws Exception {
|
||||
SuiteContext suiteContext = this.suiteContext.get();
|
||||
//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
|
||||
|
@ -298,6 +298,8 @@ public abstract class AbstractQuarkusDeployableContainer implements DeployableCo
|
|||
throw new IllegalStateException("Timeout [" + getStartTimeout() + "] while waiting for Quarkus server");
|
||||
}
|
||||
|
||||
checkLiveness();
|
||||
|
||||
try {
|
||||
// wait before checking for opening a new connection
|
||||
Thread.sleep(1000);
|
||||
|
@ -325,6 +327,8 @@ public abstract class AbstractQuarkusDeployableContainer implements DeployableCo
|
|||
log.infof("Keycloak is ready at %s", contextRoot);
|
||||
}
|
||||
|
||||
protected abstract void checkLiveness() throws Exception;
|
||||
|
||||
private URL getBaseUrl(SuiteContext suiteContext) throws MalformedURLException {
|
||||
URL baseUrl = suiteContext.getAuthServerInfo().getContextRoot();
|
||||
|
||||
|
|
|
@ -49,7 +49,18 @@ public class KeycloakContainerFeaturesController {
|
|||
|
||||
public enum FeatureAction {
|
||||
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;
|
||||
|
||||
|
@ -85,17 +96,18 @@ public class KeycloakContainerFeaturesController {
|
|||
" feature " + feature.getKey() + ", however after performing this operation " +
|
||||
"the feature is not in desired state" ,
|
||||
ProfileAssume.isFeatureEnabled(feature),
|
||||
is(action == FeatureAction.ENABLE));
|
||||
is(action == FeatureAction.ENABLE || action == FeatureAction.ENABLE_AND_RESET));
|
||||
}
|
||||
|
||||
public void performAction() {
|
||||
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);
|
||||
SetDefaultProvider setDefaultProvider = annotatedElement.getAnnotation(SetDefaultProvider.class);
|
||||
if (setDefaultProvider != null) {
|
||||
try {
|
||||
if (action == FeatureAction.ENABLE) {
|
||||
if (action == FeatureAction.ENABLE || action == FeatureAction.ENABLE_AND_RESET) {
|
||||
SpiProvidersSwitchingUtils.addProviderDefaultValue(suiteContextInstance.get(), setDefaultProvider);
|
||||
} else {
|
||||
SpiProvidersSwitchingUtils.removeProvider(suiteContextInstance.get(), setDefaultProvider);
|
||||
|
@ -178,12 +190,12 @@ public class KeycloakContainerFeaturesController {
|
|||
|
||||
ret.addAll(Arrays.stream(annotatedElement.getAnnotationsByType(EnableFeature.class))
|
||||
.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()));
|
||||
|
||||
ret.addAll(Arrays.stream(annotatedElement.getAnnotationsByType(DisableFeature.class))
|
||||
.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()));
|
||||
|
||||
return ret;
|
||||
|
|
|
@ -53,4 +53,9 @@ public class KeycloakQuarkusEmbeddedDeployableContainer extends AbstractQuarkusD
|
|||
System.setProperty("quarkus.http.test-ssl-port", String.valueOf(configuration.getBindHttpsPort()));
|
||||
return args;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void checkLiveness() {
|
||||
// no-op, Keycloak would throw an exception in the test JVM if something went wrong
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,9 +1,16 @@
|
|||
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.File;
|
||||
import java.io.FileWriter;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.net.URISyntaxException;
|
||||
import java.net.URL;
|
||||
import java.nio.file.FileVisitResult;
|
||||
|
@ -23,16 +30,10 @@ import java.util.concurrent.CompletableFuture;
|
|||
import java.util.concurrent.TimeUnit;
|
||||
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
|
||||
*/
|
||||
public class KeycloakQuarkusServerDeployableContainer extends AbstractQuarkusDeployableContainer {
|
||||
public class KeycloakQuarkusServerDeployableContainer extends AbstractQuarkusDeployableContainer implements RemoteContainer {
|
||||
|
||||
private static final int DEFAULT_SHUTDOWN_TIMEOUT_SECONDS = 10;
|
||||
|
||||
|
@ -40,13 +41,15 @@ public class KeycloakQuarkusServerDeployableContainer extends AbstractQuarkusDep
|
|||
|
||||
private Process container;
|
||||
private Thread stdoutForwarderThread;
|
||||
private LogProcessor logProcessor;
|
||||
|
||||
@Override
|
||||
public void start() throws LifecycleException {
|
||||
try {
|
||||
importRealm();
|
||||
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();
|
||||
waitForReadiness();
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
|
@ -36,10 +36,10 @@ public class WelcomePage extends AuthServer {
|
|||
@FindBy(id = "password")
|
||||
private WebElement passwordInput;
|
||||
|
||||
@FindBy(id = "passwordConfirmation")
|
||||
@FindBy(id = "password-confirmation")
|
||||
private WebElement passwordConfirmationInput;
|
||||
|
||||
@FindBy(id = "create-button")
|
||||
@FindBy(tagName = "button")
|
||||
private WebElement createButton;
|
||||
|
||||
@FindBy(css = ".welcome-header h1")
|
||||
|
@ -47,7 +47,8 @@ public class WelcomePage extends AuthServer {
|
|||
|
||||
public boolean isPasswordSet() {
|
||||
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) {
|
||||
|
|
|
@ -86,7 +86,9 @@ public class UmaDiscoveryDocumentTest extends AbstractKeycloakTest {
|
|||
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);
|
||||
|
||||
|
@ -101,12 +103,13 @@ public class UmaDiscoveryDocumentTest extends AbstractKeycloakTest {
|
|||
UmaConfiguration configuration = response.readEntity(UmaConfiguration.class);
|
||||
|
||||
String baseBackendUri = UriBuilder
|
||||
.fromUri(OAuthClient.AUTH_SERVER_ROOT)
|
||||
.fromUri(frontendUrl)
|
||||
.path(RealmsResource.class).path(RealmsResource.class, "getRealmResource").build(realmsResouce().realm("test").toRepresentation().getRealm()).toString();
|
||||
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();
|
||||
|
||||
// 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(baseFrontendUri + "/protocol/openid-connect/auth", configuration.getAuthorizationEndpoint());
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,19 +1,11 @@
|
|||
{
|
||||
|
||||
"hostname": {
|
||||
"provider": "${keycloak.hostname.provider:default}",
|
||||
|
||||
"fixed": {
|
||||
"hostname": "${keycloak.hostname.fixed.hostname:localhost}",
|
||||
"httpPort": "${keycloak.hostname.fixed.httpPort:-1}",
|
||||
"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}"
|
||||
"v2": {
|
||||
"hostname": "${keycloak.hostname:}",
|
||||
"hostname-admin": "${keycloak.hostname-admin:}",
|
||||
"hostname-backchannel-dynamic": "${keycloak.hostname-backchannel-dynamic:}",
|
||||
"hostname-strict": "${keycloak.hostname-strict:}"
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
@ -1,15 +1,5 @@
|
|||
{
|
||||
|
||||
"hostname": {
|
||||
"provider": "${keycloak.hostname.provider:}",
|
||||
|
||||
"default": {
|
||||
"frontendUrl": "${keycloak.frontendUrl:}",
|
||||
"adminUrl": "${keycloak.adminUrl:}",
|
||||
"forceBackendUrlToFrontendUrl": "${keycloak.hostname.default.forceBackendUrlToFrontendUrl:}"
|
||||
}
|
||||
},
|
||||
|
||||
"eventsStore": {
|
||||
"provider": "${keycloak.eventsStore.provider:jpa}",
|
||||
"jpa": {
|
||||
|
|
Loading…
Reference in a new issue