[KEYCLOAK-19798] - Hostname support for Dist.X

Co-authored-by: Stian Thorgersen <stian@redhat.com>
This commit is contained in:
Pedro Igor 2021-11-16 12:30:44 -03:00
parent 51ccf58dff
commit e14e56e0f3
15 changed files with 441 additions and 75 deletions

View file

@ -127,6 +127,9 @@ import org.keycloak.quarkus.runtime.services.health.KeycloakMetricsHandler;
import org.keycloak.theme.FolderThemeProviderFactory;
import org.keycloak.transaction.JBossJtaTransactionManagerLookup;
import org.keycloak.quarkus.runtime.Environment;
import org.keycloak.url.DefaultHostnameProviderFactory;
import org.keycloak.url.FixedHostnameProviderFactory;
import org.keycloak.url.RequestHostnameProviderFactory;
import org.keycloak.util.JsonSerialization;
class KeycloakProcessor {
@ -143,7 +146,10 @@ class KeycloakProcessor {
DefaultJpaConnectionProviderFactory.class,
DefaultLiquibaseConnectionProvider.class,
FolderThemeProviderFactory.class,
LiquibaseJpaUpdaterProviderFactory.class);
LiquibaseJpaUpdaterProviderFactory.class,
DefaultHostnameProviderFactory.class,
FixedHostnameProviderFactory.class,
RequestHostnameProviderFactory.class);
static {
DEPLOYEABLE_SCRIPT_PROVIDERS.put(AUTHENTICATORS, KeycloakProcessor::registerScriptAuthenticator);

View file

@ -1,5 +1,7 @@
http.enabled=true
cluster=local
hostname.strict=false
hostname.strict-https=false
db=h2-mem
db.username = sa
db.password = keycloak

View file

@ -403,6 +403,7 @@ public final class Picocli {
.completionCandidates(expectedValues)
.parameterConsumer(PropertyMapperParameterConsumer.INSTANCE)
.type(String.class)
.hidden(mapper.isHidden())
.build());
}

View file

@ -16,14 +16,18 @@ final class DatabasePropertyMappers {
public static PropertyMapper[] getDatabasePropertyMappers() {
return new PropertyMapper[] {
builder().from("db")
builder().from("db-dialect")
.mapFrom("db")
.to("quarkus.hibernate-orm.dialect")
.isBuildTimeProperty(true)
.transformer((db, context) -> Database.getDialect(db).orElse(null))
.hidden(true)
.build(),
builder().from("db")
builder().from("db-driver")
.mapFrom("db")
.to("quarkus.datasource.jdbc.driver")
.transformer((db, context) -> Database.getDriver(db).orElse(null))
.hidden(true)
.build(),
builder().from("db").
to("quarkus.datasource.db-kind")
@ -33,9 +37,11 @@ final class DatabasePropertyMappers {
.paramLabel("vendor")
.expectedValues(asList(Database.getAliases()))
.build(),
builder().from("db")
builder().from("db-tx-type")
.mapFrom("db")
.to("quarkus.datasource.jdbc.transactions")
.transformer((db, context) -> "xa")
.hidden(true)
.build(),
builder().from("db.url")
.to("quarkus.datasource.jdbc.url")

View file

@ -1,29 +1,44 @@
package org.keycloak.quarkus.runtime.configuration.mappers;
import java.util.Arrays;
final class HostnamePropertyMappers {
private HostnamePropertyMappers(){}
public static PropertyMapper[] getHostnamePropertyMappers() {
return new PropertyMapper[] {
builder().from("hostname-frontend-url")
.to("kc.spi.hostname.default.frontend-url")
.description("The URL that should be used to serve frontend requests that are usually sent through a public domain.")
builder().from("hostname")
.to("kc.spi.hostname.default.hostname")
.description("Hostname for the Keycloak server.")
.paramLabel("hostname")
.build(),
builder().from("hostname.admin")
.to("kc.spi.hostname.default.admin")
.description("Overrides the hostname for the admin console and APIs.")
.paramLabel("url")
.build(),
builder().from("hostname-admin-url")
.to("kc.spi.hostname.default.admin-url")
.description("The URL that should be used to expose the admin endpoints and console.")
.paramLabel("url")
builder().from("hostname.strict")
.to("kc.spi.hostname.default.strict")
.description("Disables dynamically resolving the hostname from request headers. Should always be set to true in production, unless proxy verifies the Host header.")
.type(Boolean.class)
.defaultValue(Boolean.TRUE.toString())
.build(),
builder().from("hostname-force-backend-url-to-frontend-url")
.to("kc.spi.hostname.default.force-backend-url-to-frontend-url")
.description("Forces backend requests to go through the URL defined as the frontend-url. Defaults to false. Possible values are true or false.")
.paramLabel(Boolean.TRUE + "|" + Boolean.FALSE)
.expectedValues(Arrays.asList(Boolean.TRUE.toString(), Boolean.FALSE.toString()))
builder().from("hostname.strict-https")
.to("kc.spi.hostname.default.strict-https")
.description("Forces URLs to use HTTPS. Only needed if proxy does not properly set the X-Forwarded-Proto header.")
.hidden(true)
.defaultValue(Boolean.TRUE.toString())
.type(Boolean.class)
.build(),
builder().from("hostname.strict-backchannel")
.to("kc.spi.hostname.default.strict-backchannel")
.description("By default backchannel URLs are dynamically resolved from request headers to allow internal an external applications. If all applications use the public URL this option should be enabled.")
.type(Boolean.class)
.build(),
builder().from("hostname.path")
.to("kc.spi.hostname.default.path")
.description("This should be set if proxy uses a different context-path for Keycloak.")
.paramLabel("path")
.build()
};
}

View file

@ -27,7 +27,7 @@ import org.keycloak.quarkus.runtime.configuration.MicroProfileConfigProvider;
public class PropertyMapper {
static PropertyMapper IDENTITY = new PropertyMapper(null, null, null, null, null,
false,null, null, false,Collections.emptyList(),null) {
false,null, null, false,Collections.emptyList(),null, true) {
@Override
public ConfigValue getOrDefault(String name, ConfigSourceInterceptorContext context, ConfigValue current) {
return current;
@ -45,9 +45,11 @@ public class PropertyMapper {
private final Iterable<String> expectedValues;
private final ConfigCategory category;
private final String paramLabel;
private final boolean hidden;
PropertyMapper(String from, String to, String defaultValue, BiFunction<String, ConfigSourceInterceptorContext, String> mapper,
String mapFrom, boolean buildTime, String description, String paramLabel, boolean mask, Iterable<String> expectedValues, ConfigCategory category) {
String mapFrom, boolean buildTime, String description, String paramLabel, boolean mask, Iterable<String> expectedValues,
ConfigCategory category, boolean hidden) {
this.from = MicroProfileConfigProvider.NS_KEYCLOAK_PREFIX + from;
this.to = to == null ? this.from : to;
this.defaultValue = defaultValue;
@ -59,6 +61,7 @@ public class PropertyMapper {
this.mask = mask;
this.expectedValues = expectedValues == null ? Collections.emptyList() : expectedValues;
this.category = category != null ? category : ConfigCategory.GENERAL;
this.hidden = hidden;
}
public static PropertyMapper.Builder builder(String fromProp, String toProp) {
@ -124,6 +127,10 @@ public class PropertyMapper {
return config;
}
if (config.getName().equals(name)) {
return config;
}
ConfigValue value = transformValue(config.getValue(), context);
// we always fallback to the current value from the property we are mapping
@ -152,6 +159,10 @@ public class PropertyMapper {
return category;
}
public boolean isHidden() {
return hidden;
}
private ConfigValue transformValue(String value, ConfigSourceInterceptorContext context) {
if (value == null) {
return null;
@ -199,6 +210,7 @@ public class PropertyMapper {
private boolean isMasked = false;
private ConfigCategory category = ConfigCategory.GENERAL;
private String paramLabel;
private boolean hidden;
public Builder(ConfigCategory category) {
this.category = category;
@ -273,12 +285,20 @@ public class PropertyMapper {
public Builder type(Class<Boolean> type) {
if (Boolean.class.equals(type)) {
expectedValues(Boolean.TRUE.toString(), Boolean.FALSE.toString());
paramLabel(defaultValue == null ? "true|false" : defaultValue);
defaultValue(defaultValue == null ? Boolean.FALSE.toString() : defaultValue);
}
return this;
}
public Builder hidden(boolean hidden) {
this.hidden = hidden;
return this;
}
public PropertyMapper build() {
return new PropertyMapper(from, to, defaultValue, mapper, mapFrom, isBuildTimeProperty, description, paramLabel, isMasked, expectedValues, category);
return new PropertyMapper(from, to, defaultValue, mapper, mapFrom, isBuildTimeProperty, description, paramLabel,
isMasked, expectedValues, category, hidden);
}
}
}

View file

@ -15,24 +15,18 @@ import java.util.stream.Collectors;
public final class PropertyMappers {
static final Map<String, PropertyMapper> MAPPERS = new HashMap<>();
static final MappersConfig MAPPERS = new MappersConfig();
private PropertyMappers(){}
static {
addMappers(ClusteringPropertyMappers.getClusteringPropertyMappers());
addMappers(DatabasePropertyMappers.getDatabasePropertyMappers());
addMappers(HostnamePropertyMappers.getHostnamePropertyMappers());
addMappers(HttpPropertyMappers.getHttpPropertyMappers());
addMappers(MetricsPropertyMappers.getMetricsPropertyMappers());
addMappers(ProxyPropertyMappers.getProxyPropertyMappers());
addMappers(VaultPropertyMappers.getVaultPropertyMappers());
}
private static void addMappers(PropertyMapper[] mappers) {
for (PropertyMapper mapper : mappers) {
MAPPERS.put(mapper.getTo(), mapper);
}
MAPPERS.addAll(ClusteringPropertyMappers.getClusteringPropertyMappers());
MAPPERS.addAll(DatabasePropertyMappers.getDatabasePropertyMappers());
MAPPERS.addAll(HostnamePropertyMappers.getHostnamePropertyMappers());
MAPPERS.addAll(HttpPropertyMappers.getHttpPropertyMappers());
MAPPERS.addAll(MetricsPropertyMappers.getMetricsPropertyMappers());
MAPPERS.addAll(ProxyPropertyMappers.getProxyPropertyMappers());
MAPPERS.addAll(VaultPropertyMappers.getVaultPropertyMappers());
}
public static ConfigValue getValue(ConfigSourceInterceptorContext context, String name) {
@ -138,12 +132,7 @@ public final class PropertyMappers {
return mapper;
}
return MAPPERS.values().stream().filter(new Predicate<PropertyMapper>() {
@Override
public boolean test(PropertyMapper propertyMapper) {
return property.equals(propertyMapper.getFrom()) || property.equals(propertyMapper.getTo());
}
}).findFirst().orElse(null);
return null;
}
public static Collection<PropertyMapper> getMappers() {
@ -168,4 +157,22 @@ public final class PropertyMappers {
.map(Map.Entry::getKey)
.findAny();
}
private static class MappersConfig extends HashMap<String, PropertyMapper> {
public void addAll(PropertyMapper[] mappers) {
for (PropertyMapper mapper : mappers) {
super.put(mapper.getTo(), mapper);
super.put(mapper.getFrom(), mapper);
}
}
@Override
public PropertyMapper put(String key, PropertyMapper value) {
if (containsKey(key)) {
throw new IllegalArgumentException("Duplicated mapper for key [" + key + "]");
}
return super.put(key, value);
}
}
}

View file

@ -0,0 +1,224 @@
/*
* 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.quarkus.runtime.hostname;
import static org.keycloak.urls.UrlType.ADMIN;
import static org.keycloak.urls.UrlType.BACKEND;
import static org.keycloak.urls.UrlType.FRONTEND;
import static org.keycloak.utils.StringUtil.isNotBlank;
import java.net.URI;
import java.util.function.BiFunction;
import java.util.function.Function;
import javax.ws.rs.core.UriInfo;
import org.jboss.logging.Logger;
import org.jboss.resteasy.spi.HttpRequest;
import org.keycloak.Config;
import org.keycloak.common.util.Resteasy;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.quarkus.runtime.configuration.Configuration;
import org.keycloak.urls.HostnameProvider;
import org.keycloak.urls.HostnameProviderFactory;
import org.keycloak.urls.UrlType;
public final class DefaultHostnameProvider implements HostnameProvider, HostnameProviderFactory {
private static final Logger LOGGER = Logger.getLogger(DefaultHostnameProvider.class);
private static final String REALM_URI_SESSION_ATTRIBUTE = DefaultHostnameProvider.class.getName() + ".realmUrl";
private String frontChannelHostName;
private String defaultPath;
private String defaultHttpScheme;
private int defaultTlsPort;
private boolean noProxy;
private String adminHostName;
private Boolean strictBackChannel;
private boolean hostnameEnabled;
@Override
public String getScheme(UriInfo originalUriInfo, UrlType urlType) {
String scheme = forNonStrictBackChannel(originalUriInfo, urlType, this::getScheme, this::getScheme);
if (scheme != null) {
return scheme;
}
return fromFrontChannel(originalUriInfo, URI::getScheme, this::getScheme, defaultHttpScheme);
}
@Override
public String getHostname(UriInfo originalUriInfo, UrlType urlType) {
String hostname = forNonStrictBackChannel(originalUriInfo, urlType, this::getHostname, this::getHostname);
if (hostname != null) {
return hostname;
}
// admin hostname has precedence over frontchannel
if (ADMIN.equals(urlType) && adminHostName != null) {
return adminHostName;
}
return fromFrontChannel(originalUriInfo, URI::getHost, this::getHostname, frontChannelHostName);
}
@Override
public String getContextPath(UriInfo originalUriInfo, UrlType urlType) {
String path = forNonStrictBackChannel(originalUriInfo, urlType, this::getContextPath, this::getContextPath);
if (path != null) {
return path;
}
if (ADMIN.equals(urlType)) {
// for admin we always resolve to the request path
return getContextPath(originalUriInfo);
}
return fromFrontChannel(originalUriInfo, URI::getPath, this::getContextPath, defaultPath);
}
@Override
public int getPort(UriInfo originalUriInfo, UrlType urlType) {
Integer port = forNonStrictBackChannel(originalUriInfo, urlType, this::getPort, this::getPort);
if (port != null) {
return port;
}
if (hostnameEnabled && !noProxy) {
// if proxy is enabled and hostname is set, assume the server is exposed using default ports
return -1;
}
return fromFrontChannel(originalUriInfo, URI::getPort, this::getPort, null);
}
@Override
public int getPort(UriInfo originalUriInfo) {
KeycloakSession session = Resteasy.getContextData(KeycloakSession.class);
int requestPort = session.getContext().getContextObject(HttpRequest.class).getUri().getBaseUri().getPort();
return noProxy ? defaultTlsPort : requestPort;
}
private <T> T forNonStrictBackChannel(UriInfo originalUriInfo, UrlType urlType,
BiFunction<UriInfo, UrlType, T> frontEndTypeResolver, Function<UriInfo, T> defaultResolver) {
if (BACKEND.equals(urlType) && !strictBackChannel) {
if (isHostFromFrontEndUrl(originalUriInfo)) {
return frontEndTypeResolver.apply(originalUriInfo, FRONTEND);
}
return defaultResolver.apply(originalUriInfo);
}
return null;
}
private <T> T fromFrontChannel(UriInfo originalUriInfo, Function<URI, T> frontEndTypeResolver, Function<UriInfo, T> defaultResolver,
T defaultValue) {
URI frontEndUrl = getRealmFrontEndUrl();
if (frontEndUrl != null) {
return frontEndTypeResolver.apply(frontEndUrl);
}
return defaultValue == null ? defaultResolver.apply(originalUriInfo) : defaultValue;
}
private boolean isHostFromFrontEndUrl(UriInfo originalUriInfo) {
String requestHost = getHostname(originalUriInfo);
String frontendUrl = getHostname(originalUriInfo, FRONTEND);
if (requestHost.equals(frontendUrl)) {
return true;
}
URI realmUrl = getRealmFrontEndUrl();
return realmUrl != null && requestHost.equals(realmUrl.getHost());
}
protected URI getRealmFrontEndUrl() {
KeycloakSession session = Resteasy.getContextData(KeycloakSession.class);
URI realmUrl = (URI) session.getAttribute(REALM_URI_SESSION_ATTRIBUTE);
if (realmUrl == null) {
RealmModel realm = session.getContext().getRealm();
if (realm != null) {
String frontendUrl = realm.getAttribute("frontendUrl");
if (isNotBlank(frontendUrl)) {
realmUrl = URI.create(frontendUrl);
session.setAttribute(DefaultHostnameProvider.REALM_URI_SESSION_ATTRIBUTE, realmUrl);
return realmUrl;
}
}
}
return realmUrl;
}
@Override
public void close() {
}
@Override
public String getId() {
return "default";
}
@Override
public HostnameProvider create(KeycloakSession session) {
return this;
}
@Override
public void init(Config.Scope config) {
frontChannelHostName = config.get("hostname");
if (config.getBoolean("strict", false) && frontChannelHostName == null) {
throw new RuntimeException("Strict hostname resolution configured but no hostname was set");
}
hostnameEnabled = frontChannelHostName != null;
Boolean strictHttps = config.getBoolean("strict-https", false);
if (strictHttps) {
defaultHttpScheme = "https";
}
defaultPath = config.get("path");
noProxy = Configuration.getConfigValue("kc.proxy").getValue().equals("none");
defaultTlsPort = Integer.parseInt(Configuration.getConfigValue("kc.https.port").getValue());
adminHostName = config.get("admin");
strictBackChannel = config.getBoolean("strict-backchannel", false);
LOGGER.infov("Hostname settings: FrontEnd: {0}, Strict HTTPS: {1}, Path: {2}, Strict BackChannel: {3}, Admin: {4}",
frontChannelHostName == null ? "<request>" : frontChannelHostName,
strictHttps,
defaultPath == null ? "<request>" : defaultPath,
strictBackChannel,
adminHostName == null ? "<request>" : adminHostName);
}
}

View file

@ -0,0 +1 @@
org.keycloak.quarkus.runtime.hostname.DefaultHostnameProvider

View file

@ -22,9 +22,12 @@ metrics.enabled=false
#%prod.https.certificate.file=${kc.home.dir}conf/server.crt.pem
#%prod.https.certificate.key-file=${kc.home.dir}conf/server.key.pem
#%prod.proxy=reencrypt
#%prod.hostname=myhostname
# Default, and insecure, and non-production grade configuration for the development profile
%dev.http.enabled=true
%dev.hostname.strict=false
%dev.hostname.strict-https=false
%dev.cluster=local
%dev.spi.theme.cache-themes=false
%dev.spi.theme.cache-templates=false
@ -32,6 +35,8 @@ metrics.enabled=false
# The default configuration when running in import or export mode
%import_export.http.enabled=true
%import_export.hostname.strict=false
%import_export.hostname.strict-https=false
%import_export.cluster=local
# Logging configuration. INFO is the default level for most of the categories

View file

@ -6,6 +6,10 @@ db.password = keycloak
# Testsuite still relies on HTTP listener
http.enabled=true
# Disables strict hostname
hostname.strict=false
hostname.strict-https=false
# SSL
https.key-store.file=${kc.home.dir}/conf/keycloak.jks
https.key-store.password=secret

View file

@ -174,6 +174,8 @@ public class OAuthClient {
private String requestUri;
private Map<String, String> requestHeaders;
private Map<String, JSONWebKeySet> publicKeys = new HashMap<>();
// https://tools.ietf.org/html/rfc7636#section-4
@ -492,6 +494,12 @@ public class OAuthClient {
public String introspectTokenWithClientCredential(String clientId, String clientSecret, String tokenType, String tokenToIntrospect, CloseableHttpClient client) {
HttpPost post = new HttpPost(getTokenIntrospectionUrl());
if (requestHeaders != null) {
for (Map.Entry<String, String> header : requestHeaders.entrySet()) {
post.addHeader(header.getKey(), header.getValue());
}
}
String authorization = BasicAuthHelper.createHeader(clientId, clientSecret);
post.setHeader("Authorization", authorization);
@ -546,6 +554,12 @@ public class OAuthClient {
try (CloseableHttpClient client = httpClient.get()) {
HttpPost post = new HttpPost(getResourceOwnerPasswordCredentialGrantUrl(realm));
if (requestHeaders != null) {
for (Map.Entry<String, String> header : requestHeaders.entrySet()) {
post.addHeader(header.getKey(), header.getValue());
}
}
List<NameValuePair> parameters = new LinkedList<>();
parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, OAuth2Constants.PASSWORD));
parameters.add(new BasicNameValuePair("username", username));
@ -1027,7 +1041,14 @@ public class OAuthClient {
public OIDCConfigurationRepresentation doWellKnownRequest(String realm) {
try (CloseableHttpClient client = HttpClientBuilder.create().build()) {
return SimpleHttp.doGet(baseUrl + "/realms/" + realm + "/.well-known/openid-configuration", client).asJson(OIDCConfigurationRepresentation.class);
SimpleHttp request = SimpleHttp.doGet(baseUrl + "/realms/" + realm + "/.well-known/openid-configuration",
client);
if (requestHeaders != null) {
for (Map.Entry<String, String> entry : requestHeaders.entrySet()) {
request.header(entry.getKey(), entry.getValue());
}
}
return request.asJson(OIDCConfigurationRepresentation.class);
} catch (IOException ex) {
throw new RuntimeException(ex);
}
@ -1611,6 +1632,11 @@ public class OAuthClient {
return this;
}
public OAuthClient requestHeaders(Map<String, String> headers) {
this.requestHeaders = headers;
return this;
}
public static class AuthorizationEndpointResponse {
private boolean isRedirected;

View file

@ -6,11 +6,12 @@ import org.jboss.logging.Logger;
import org.keycloak.testsuite.AbstractKeycloakTest;
import org.keycloak.testsuite.arquillian.AuthServerTestEnricher;
import org.keycloak.testsuite.arquillian.containers.KeycloakQuarkusServerDeployableContainer;
import org.keycloak.testsuite.util.OAuthClient;
import org.wildfly.extras.creaper.core.online.OnlineManagementClient;
import org.wildfly.extras.creaper.core.online.operations.admin.Administration;
import java.net.URI;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public abstract class AbstractHostnameTest extends AbstractKeycloakTest {
@ -39,11 +40,10 @@ public abstract class AbstractHostnameTest extends AbstractKeycloakTest {
"/subsystem=keycloak-server/spi=hostname/:add(default-provider=default)",
"/subsystem=keycloak-server/spi=hostname/provider=default/:add(properties={frontendUrl => \"${keycloak.frontendUrl:}\",forceBackendUrlToFrontendUrl => \"false\"},enabled=true)");
} else if (suiteContext.getAuthServerInfo().isQuarkus()) {
controller.stop(suiteContext.getAuthServerInfo().getQualifier());
KeycloakQuarkusServerDeployableContainer container = (KeycloakQuarkusServerDeployableContainer)suiteContext.getAuthServerInfo().getArquillianContainer().getDeployableContainer();
container.resetConfiguration();
container.setAdditionalBuildArgs(Collections.singletonList("--spi-hostname-provider=default"));
controller.start(suiteContext.getAuthServerInfo().getQualifier());
configureDefault(OAuthClient.AUTH_SERVER_ROOT, false, null);
container.restartServer();
} else {
throw new RuntimeException("Don't know how to config");
}
@ -74,10 +74,18 @@ public abstract class AbstractHostnameTest extends AbstractKeycloakTest {
controller.stop(suiteContext.getAuthServerInfo().getQualifier());
KeycloakQuarkusServerDeployableContainer container = (KeycloakQuarkusServerDeployableContainer)suiteContext.getAuthServerInfo().getArquillianContainer().getDeployableContainer();
List<String> additionalArgs = new ArrayList<>();
additionalArgs.add("--spi-hostname-default-frontend-url="+frontendUrl);
additionalArgs.add("--spi-hostname-default-force-backend-url-to-frontend-url="+ forceBackendUrlToFrontendUrl);
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);
if (adminUrl != null) {
additionalArgs.add("--spi-hostname-default-admin-url="+adminUrl);
URI adminUri = URI.create(adminUrl);
additionalArgs.add("--hostname-admin=" + adminUri.getHost());
}
container.setAdditionalBuildArgs(additionalArgs);
controller.start(suiteContext.getAuthServerInfo().getQualifier());
@ -102,15 +110,6 @@ public abstract class AbstractHostnameTest extends AbstractKeycloakTest {
executeCli("/subsystem=keycloak-server/spi=hostname:remove",
"/subsystem=keycloak-server/spi=hostname/:add(default-provider=fixed)",
"/subsystem=keycloak-server/spi=hostname/provider=fixed/:add(properties={hostname => \"" + hostname + "\",httpPort => \"" + httpPort + "\",httpsPort => \"" + httpsPort + "\",alwaysHttps => \"" + alwaysHttps + "\"},enabled=true)");
} else if (suiteContext.getAuthServerInfo().isQuarkus()) {
controller.stop(suiteContext.getAuthServerInfo().getQualifier());
KeycloakQuarkusServerDeployableContainer container = (KeycloakQuarkusServerDeployableContainer)suiteContext.getAuthServerInfo().getArquillianContainer().getDeployableContainer();
container.setAdditionalBuildArgs(Collections.singletonList("--spi-hostname-provider=fixed" +
" --spi-hostname-fixed-hostname=" + hostname +
" --spi-hostname-fixed-http-port=" + httpPort +
" --spi-hostname-fixed-https-port=" + httpsPort +
" --spi-hostname-fixed-always-https=" + alwaysHttps));
controller.start(suiteContext.getAuthServerInfo().getQualifier());
} else {
throw new RuntimeException("Don't know how to config");
}

View file

@ -34,7 +34,9 @@ import org.keycloak.testsuite.util.UserBuilder;
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;
@ -42,6 +44,8 @@ import static org.keycloak.testsuite.arquillian.annotation.AuthServerContainerEx
import static org.keycloak.testsuite.util.OAuthClient.AUTH_SERVER_ROOT;
import static org.keycloak.testsuite.util.ServerURLs.getAuthServerContextRoot;
import javax.ws.rs.core.UriBuilder;
@AuthServerContainerExclude({REMOTE})
public class DefaultHostnameTest extends AbstractHostnameTest {
@ -72,7 +76,7 @@ public class DefaultHostnameTest extends AbstractHostnameTest {
@Test
public void fixedFrontendUrl() throws Exception {
expectedBackendUrl = AUTH_SERVER_ROOT;
expectedBackendUrl = transformUrlIfQuarkusServer(AUTH_SERVER_ROOT);
oauth.clientId("direct-grant");
@ -86,15 +90,14 @@ public class DefaultHostnameTest extends AbstractHostnameTest {
assertInitialAccessTokenFromMasterRealm(testAdminClient,"test", globalFrontEndUrl);
assertBackendForcedToFrontendWithMatchingHostname("test", globalFrontEndUrl);
assertWelcomePage(globalFrontEndUrl);
assertAdminPage("master", globalFrontEndUrl, globalFrontEndUrl);
assertAdminPage("master", globalFrontEndUrl, transformUrlIfQuarkusServer(globalFrontEndUrl, true));
assertWellKnown("frontendUrl", realmFrontEndUrl);
assertTokenIssuer("frontendUrl", realmFrontEndUrl);
assertInitialAccessTokenFromMasterRealm(testAdminClient,"frontendUrl", realmFrontEndUrl);
assertBackendForcedToFrontendWithMatchingHostname("frontendUrl", realmFrontEndUrl);
assertAdminPage("frontendUrl", realmFrontEndUrl, realmFrontEndUrl);
assertAdminPage("frontendUrl", realmFrontEndUrl, transformUrlIfQuarkusServer(realmFrontEndUrl, true));
} finally {
reset();
}
@ -102,8 +105,8 @@ public class DefaultHostnameTest extends AbstractHostnameTest {
// KEYCLOAK-12953
@Test
public void emptyRealmFrontendUrl() throws URISyntaxException {
expectedBackendUrl = AUTH_SERVER_ROOT;
public void emptyRealmFrontendUrl() throws Exception {
expectedBackendUrl = transformUrlIfQuarkusServer(AUTH_SERVER_ROOT);
oauth.clientId("direct-grant");
RealmResource realmResource = realmsResouce().realm("frontendUrl");
@ -113,17 +116,18 @@ public class DefaultHostnameTest extends AbstractHostnameTest {
rep.getAttributes().put("frontendUrl", "");
realmResource.update(rep);
assertWellKnown("frontendUrl", AUTH_SERVER_ROOT);
assertWellKnown("frontendUrl", transformUrlIfQuarkusServer(AUTH_SERVER_ROOT));
} finally {
rep.getAttributes().put("frontendUrl", realmFrontEndUrl);
realmResource.update(rep);
reset();
}
}
@Test
public void fixedAdminUrl() throws Exception {
expectedBackendUrl = AUTH_SERVER_ROOT;
String adminUrl = "https://admin.127.0.0.1.nip.io/custom-admin";
expectedBackendUrl = transformUrlIfQuarkusServer(AUTH_SERVER_ROOT);
String adminUrl = transformUrlIfQuarkusServer("https://admin.127.0.0.1.nip.io/custom-admin", true);
oauth.clientId("direct-grant");
@ -143,7 +147,7 @@ public class DefaultHostnameTest extends AbstractHostnameTest {
@Test
public void forceBackendUrlToFrontendUrl() throws Exception {
expectedBackendUrl = AUTH_SERVER_ROOT;
expectedBackendUrl = transformUrlIfQuarkusServer(AUTH_SERVER_ROOT);
oauth.clientId("direct-grant");
@ -191,6 +195,7 @@ public class DefaultHostnameTest extends AbstractHostnameTest {
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");
@ -204,8 +209,8 @@ public class DefaultHostnameTest extends AbstractHostnameTest {
assertEquals(expectedBaseUrl + "/realms/" + realm, introspectionNode.get("iss").asText());
}
private void assertWellKnown(String realm, String expectedFrontendUrl) throws URISyntaxException {
OIDCConfigurationRepresentation config = oauth.doWellKnownRequest(realm);
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());
@ -216,6 +221,17 @@ public class DefaultHostnameTest extends AbstractHostnameTest {
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();
@ -223,7 +239,7 @@ public class DefaultHostnameTest extends AbstractHostnameTest {
// 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.doWellKnownRequest(realm);
OIDCConfigurationRepresentation config = oauth.requestHeaders(createRequestHeaders(expectedFrontendUrl)).doWellKnownRequest(realm);
assertEquals(expectedFrontendUrl + "/realms/" + realm, config.getIssuer());
assertEquals(expectedFrontendUrl + "/realms/" + realm + "/protocol/openid-connect/auth", config.getAuthorizationEndpoint());
@ -239,14 +255,26 @@ public class DefaultHostnameTest extends AbstractHostnameTest {
private void assertWelcomePage(String expectedAdminUrl) throws IOException {
try (CloseableHttpClient client = HttpClientBuilder.create().build()) {
String welcomePage = SimpleHttp.doGet(AUTH_SERVER_ROOT + "/", client).asString();
SimpleHttp get = SimpleHttp.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 assertAdminPage(String realm, String expectedFrontendUrl, String expectedAdminUrl) throws IOException, URISyntaxException {
try (CloseableHttpClient client = HttpClientBuilder.create().build()) {
SimpleHttp.Response response = SimpleHttp.doGet(AUTH_SERVER_ROOT + "/admin/" + realm +"/console/", client).asResponse();
SimpleHttp get = SimpleHttp.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 +"'"));
@ -264,4 +292,24 @@ public class DefaultHostnameTest extends AbstractHostnameTest {
}
}
public String transformUrlIfQuarkusServer(String expectedUrl) {
return transformUrlIfQuarkusServer(expectedUrl, false);
}
public String transformUrlIfQuarkusServer(String expectedUrl, boolean adminUrl) {
if (suiteContext.getAuthServerInfo().isQuarkus()) {
// for quarkus, when proxy is enabled we always default to the default https and http ports.
UriBuilder uriBuilder = UriBuilder.fromUri(expectedUrl).port(-1);
if (adminUrl) {
// for quarkus, the path is set from the request. As we are not running behind a proxy, that means defaults to /auth.
uriBuilder.replacePath("/auth");
}
return uriBuilder.build().toString();
}
return expectedUrl;
}
}

View file

@ -57,12 +57,14 @@ import static org.hamcrest.Matchers.notNullValue;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.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;
import static org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude.AuthServer.REMOTE;
@AuthServerContainerExclude({REMOTE})
@AuthServerContainerExclude(value = {REMOTE, 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/";