[KEYCLOAK-19798] - Hostname support for Dist.X
Co-authored-by: Stian Thorgersen <stian@redhat.com>
This commit is contained in:
parent
51ccf58dff
commit
e14e56e0f3
15 changed files with 441 additions and 75 deletions
|
@ -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);
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -403,6 +403,7 @@ public final class Picocli {
|
|||
.completionCandidates(expectedValues)
|
||||
.parameterConsumer(PropertyMapperParameterConsumer.INSTANCE)
|
||||
.type(String.class)
|
||||
.hidden(mapper.isHidden())
|
||||
.build());
|
||||
}
|
||||
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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()
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
org.keycloak.quarkus.runtime.hostname.DefaultHostnameProvider
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
if (adminUrl != null){
|
||||
additionalArgs.add("--spi-hostname-default-admin-url="+adminUrl);
|
||||
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) {
|
||||
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");
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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/";
|
||||
|
|
Loading…
Reference in a new issue