Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package org.keycloak.common.profile;

import org.keycloak.common.Profile;

import java.util.Collections;
import java.util.Map;
import java.util.Optional;

// Features configuration based on the option 'feature-<name>'
public class SingleProfileConfigResolver implements ProfileConfigResolver {
private final Map<String, Boolean> features;

public SingleProfileConfigResolver(Map<String, Boolean> features) {
this.features = Optional.ofNullable(features).orElseGet(Collections::emptyMap);
}

@Override
public Profile.ProfileName getProfileName() {
Boolean state = features.get(Profile.ProfileName.PREVIEW.name().toLowerCase());
if (state != null && state) {
return Profile.ProfileName.PREVIEW;
}

return null;
}

@Override
public FeatureConfig getFeatureConfig(String feature) {
Boolean state = features.get(feature);
if (state == null) {
return FeatureConfig.UNCONFIGURED;
}

return state ? FeatureConfig.ENABLED : FeatureConfig.DISABLED;
}
}
10 changes: 10 additions & 0 deletions docs/documentation/release_notes/topics/26_5_0.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,13 @@ For more details, see the https://www.keycloak.org/server/configuration-producti
This release introduces a breaking change for Windows users: setups that previously relied on custom machine names or non-standard hostnames for loopback (e.g., `127.0.0.1` resolving to a custom name) may require updates to their trusted domain configuration. Only `localhost` and `*.localhost` are now recognized for loopback verification.

Keycloak now consistently normalizes loopback addresses to `localhost` for domain verification across all platforms. This change ensures predictable behavior for trusted domain checks, regardless of the underlying OS.

= Enable/disable features via a single option

You can now enable or disable individual features using the `feature-<name>` option (like `feature-spiffe=enabled`).

This provides a more fine-grained way to manage features and eliminates the need to maintain long lists of enabled or disabled features.

The `feature-<name>` option takes precedence over both `features` and `features-disabled`.

For more details, see the https://www.keycloak.org/server/features[Enabling and disabling features] guide.
45 changes: 40 additions & 5 deletions docs/guides/server/features.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,33 @@
<@tmpl.guide
title="Enabling and disabling features"
summary="Configure {project_name} to use optional features."
includedOptions="features features-*">
includedOptions="features features-* feature-*">

{project_name} has packed some functionality in features, including some disabled features, such as Technology Preview and deprecated features. Other features are enabled by default, but you can disable them if they do not apply to your use of {project_name}.

== Enabling features

Some supported features, and all preview features, are disabled by default. To enable a feature, enter this command:
Some supported features, and all preview features, are disabled by default.
You can enable feature either via single option or including it into the list of enabled feature.

=== Single option

You can enable the specific feature `<name>` as follows:

<@kc.build parameters="--feature-<name>=enabled|disabled|vX"/>

Possible values are `enabled`, `disabled`, or a specific version of the feature that should be enabled.
For example, to enable `rolling-updates:v2` and `token-exchange`, enter this command:

<@kc.build parameters="--feature-rolling-updates=v2 --feature-token-exchange=enabled"/>

To enable all preview features, enter this command:

<@kc.build parameters="--feature-preview=enabled"/>

The single-option mechanism is useful when updating long feature lists is cumbersome or when you want to modify a specific feature without overriding the entire list in a pre-built image.

=== List of enabled features

<@kc.build parameters="--features=\"<name>[,<name>]\""/>

Expand All @@ -23,6 +43,8 @@ To enable all preview features, enter this command:

<@kc.build parameters="--features=\"preview\""/>

=== Versioning

Enabled feature may be versioned, or unversioned. If you use a versioned feature name, e.g. feature:v1, that exact feature version will be enabled as long as it still exists in the runtime. If you instead use an unversioned name, e.g. just feature, the selection of the particular supported feature version may change from release to release according to the following precedence:

. The highest default supported version
Expand All @@ -33,7 +55,22 @@ Enabled feature may be versioned, or unversioned. If you use a versioned featur

== Disabling features

To disable a feature that is enabled by default, enter this command:
To disable a feature that is enabled by default, you can use a single option or a list of disabled features.
When a feature is disabled, all versions of that feature are disabled.

=== Single option

You can disable the specific feature `<name>` as follows:

<@kc.build parameters="--feature-<name>=disabled"/>

For example, to disable `dpop` and `recovery-codes`, enter this command:

<@kc.build parameters="--feature-dpop=disabled --feature-recovery-codes=disabled"/>

The single-option mechanism is useful when updating long feature lists is cumbersome or when you want to modify a specific feature without overriding the entire list in a pre-built image.

=== List of disabled features

<@kc.build parameters="--features-disabled=\"<name>[,<name>]\""/>

Expand All @@ -43,8 +80,6 @@ For example to disable `impersonation`, enter this command:

It is not allowed to have a feature in both the `features-disabled` list and the `features` list.

When a feature is disabled all versions of that feature are disabled.

== Supported features

The following list contains supported features that are enabled by default, and can be disabled if not needed.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ public class FeatureOptions {
.buildTime(true)
.build();

public static final Option<String> FEATURE = new OptionBuilder<>("feature-<name>", String.class)
.category(OptionCategory.FEATURE)
.description("Enable/Disable specific feature <feature>. It takes precedence over the '%s', and '%s' options. Possible values are: 'enabled', 'disabled', or specific version (lowercase) that will be enabled (f.e. 'v2')".formatted(FEATURES.getKey(), FEATURES_DISABLED.getKey()))
.buildTime(true)
.build();

public static List<String> getFeatureValues(boolean toEnable) {
List<String> features = new ArrayList<>();

Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,74 @@
package org.keycloak.quarkus.runtime;

import org.keycloak.common.Profile;
import org.keycloak.common.profile.CommaSeparatedListProfileConfigResolver;
import org.keycloak.common.profile.ProfileConfigResolver;
import org.keycloak.common.profile.SingleProfileConfigResolver;
import org.keycloak.config.FeatureOptions;
import org.keycloak.quarkus.runtime.cli.PropertyException;
import org.keycloak.quarkus.runtime.configuration.Configuration;
import org.keycloak.quarkus.runtime.configuration.mappers.WildcardPropertyMapper;

public class QuarkusProfileConfigResolver extends CommaSeparatedListProfileConfigResolver {
import java.util.HashMap;
import java.util.Map;

import static org.keycloak.quarkus.runtime.configuration.MicroProfileConfigProvider.NS_KEYCLOAK_PREFIX;

public class QuarkusProfileConfigResolver implements ProfileConfigResolver {
private final CommaSeparatedListProfileConfigResolver commaSeparatedResolver;
private final SingleProfileConfigResolver singleResolver;

public QuarkusProfileConfigResolver() {
super(getConfig("kc.features"), getConfig("kc.features-disabled"));
this.commaSeparatedResolver = new CommaSeparatedListProfileConfigResolver(getConfig("kc.features"), getConfig("kc.features-disabled"));
this.singleResolver = new SingleProfileConfigResolver(getQuarkusFeatureState());
}

static String getConfig(String key) {
return Configuration.getRawPersistedProperty(key)
.orElse(Configuration.getConfigValue(key).getValue());
}

protected Map<String, Boolean> getQuarkusFeatureState() {
var map = new HashMap<String, Boolean>();
var index = FeatureOptions.FEATURE.getKey().indexOf(WildcardPropertyMapper.WILDCARD_FROM_START);
var featureEnabledOptionPrefix = NS_KEYCLOAK_PREFIX + FeatureOptions.FEATURE.getKey().substring(0, index);

Configuration.getPropertyNames().forEach(property -> {
if (property.startsWith(NS_KEYCLOAK_PREFIX) && property.startsWith(featureEnabledOptionPrefix)) {
var feature = property.substring(featureEnabledOptionPrefix.length());
var value = Configuration.getOptionalValue(property).orElseThrow(
() -> new PropertyException("Missing value for feature '%s'".formatted(feature)));

if (value.startsWith("v")) {
map.put(feature + ":" + value, true);
} else {
map.put(feature, switch (value) {
case "enabled" -> Boolean.TRUE;
case "disabled" -> Boolean.FALSE;
default -> null;
});
}
}
});

return map;
}

@Override
public Profile.ProfileName getProfileName() {
var singleConfig = singleResolver.getProfileName();
if (singleConfig != null) {
return singleConfig;
}
return commaSeparatedResolver.getProfileName();
}

@Override
public FeatureConfig getFeatureConfig(String feature) {
var singleConfig = singleResolver.getFeatureConfig(feature);
if (singleConfig != FeatureConfig.UNCONFIGURED) {
return singleConfig;
}
return commaSeparatedResolver.getFeatureConfig(feature);
}
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
package org.keycloak.quarkus.runtime.configuration.mappers;

import jakarta.ws.rs.core.MultivaluedHashMap;
import org.keycloak.common.Profile;
import org.keycloak.common.Profile.Feature;
import org.keycloak.config.FeatureOptions;
import org.keycloak.quarkus.runtime.cli.Picocli;
import org.keycloak.quarkus.runtime.cli.PropertyException;
import org.keycloak.quarkus.runtime.cli.command.AbstractCommand;
import picocli.CommandLine;

import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
Expand All @@ -14,10 +19,9 @@
import static org.keycloak.quarkus.runtime.configuration.mappers.PropertyMapper.fromOption;

public final class FeaturePropertyMappers implements PropertyMapperGrouping {

private static final Pattern VERSION_SUFFIX_PATTERN = Pattern.compile("^v(\\d+)$");
private static final Pattern VERSIONED_PATTERN = Pattern.compile("([^:]+):v(\\d+)");


@Override
public List<PropertyMapper<?>> getPropertyMappers() {
return List.of(
Expand All @@ -27,10 +31,64 @@ public List<PropertyMapper<?>> getPropertyMappers() {
.build(),
fromOption(FeatureOptions.FEATURES_DISABLED)
.paramLabel("feature")
.build(),
fromOption(FeatureOptions.FEATURE)
.paramLabel("enabled|disabled|vX(X is version)")
.wildcardKeysValidator(FeaturePropertyMappers::validateSingleFeature)
.build()
);
}

@Override
public void validateConfig(Picocli picocli) {
validateSingleFeatureDuplicates(picocli);
}

// Verify a feature specified via '--feature-<name>' is set only once on CLI
private static void validateSingleFeatureDuplicates(Picocli picocli) {
var featurePrefix = "--" + FeatureOptions.FEATURE.getKey().substring(0, FeatureOptions.FEATURE.getKey().indexOf(WildcardPropertyMapper.WILDCARD_FROM_START));
var args = picocli.getParsedCommand()
.flatMap(AbstractCommand::getCommandLine)
.map(CommandLine::getParseResult)
.map(CommandLine.ParseResult::expandedArgs)
.orElseGet(List::of);

var duplicatedFeatures = new MultivaluedHashMap<String, String>();
args.stream()
.filter(f -> f.startsWith(featurePrefix))
.map(f -> f.substring(f.indexOf("--")))
.map(f -> f.split("=", 2))
.filter(f -> f.length == 2)
.forEach(f -> duplicatedFeatures.add(f[0], f[1]));

var duplicatedFeaturesNames = duplicatedFeatures.entrySet().stream()
.filter(f -> f.getValue().size() > 1)
.map(Map.Entry::getKey)
.collect(Collectors.toSet());

if (!duplicatedFeaturesNames.isEmpty()) {
throw new PropertyException("Duplicated options for features: %s. You need to set it only once.".formatted(String.join(", ", duplicatedFeaturesNames)));
}
}

public static void validateSingleFeature(String feature, String value) {
if (feature.equals(Profile.Feature.Type.PREVIEW.name().toLowerCase())) {
if (!value.equals("enabled") && !value.equals("disabled")) {
throw new PropertyException("Wrong value for features profile '%s': %s. You can specify either 'enabled' or 'disabled'.".formatted(feature, value));
}
} else if (!Profile.getAllUnversionedFeatureNames().contains(feature)) {
throw new PropertyException("'%s' is an unrecognized feature, it should be one of %s".formatted(feature, FeatureOptions.getFeatureValues(false)));
}

Matcher matcher = VERSION_SUFFIX_PATTERN.matcher(value);
if (matcher.matches()) {
int version = Integer.parseInt(matcher.group(1));
validateFeatureVersions(feature, version);
} else if (!value.equals("enabled") && !value.equals("disabled")) {
throw new PropertyException("Wrong value for feature '%s': %s. You can specify either 'enabled', 'disabled', or specific version (lowercase) that will be enabled".formatted(feature, value));
}
}

public static void validateEnabledFeature(String feature) {
if (!Profile.getFeatureVersions(feature).isEmpty()) {
return;
Expand All @@ -45,19 +103,19 @@ public static void validateEnabledFeature(String feature) {
"%s has an invalid format for enabling a feature, expected format is feature:v{version}, e.g. docker:v1",
feature));
}
throw new PropertyException(String.format("%s is an unrecognized feature, it should be one of %s", feature,
throw new PropertyException(String.format("'%s' is an unrecognized feature, it should be one of %s", feature,
FeatureOptions.getFeatureValues(false)));
}
String unversionedFeature = matcher.group(1);
Set<Feature> featureVersions = Profile.getFeatureVersions(unversionedFeature);
if (featureVersions.isEmpty()) {
throw new PropertyException(String.format("%s has an unrecognized feature, it should be one of %s",
feature, FeatureOptions.getFeatureValues(false)));
}
int version = Integer.parseInt(matcher.group(2));
if (!featureVersions.stream().anyMatch(f -> f.getVersion() == version)) {
validateFeatureVersions(unversionedFeature, version);
}

private static void validateFeatureVersions(String feature, int version) {
Set<Feature> featureVersions = Profile.getFeatureVersions(feature);
if (featureVersions.isEmpty() || featureVersions.stream().noneMatch(f -> f.getVersion() == version)) {
throw new PropertyException(
String.format("%s has an unrecognized feature version, it should be one of %s", feature,
String.format("Feature '%s' has an unrecognized feature version, it should be one of %s", feature,
featureVersions.stream().map(Feature::getVersion).map(String::valueOf).collect(Collectors.toList())));
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,26 @@ public Builder<T> wildcardMapFrom(String mapFrom, ValueMapper function) {
return this;
}

/**
* Validates wildcard keys.
* You can validate whether an allowed key is provided as the wildcard key.
* <p>
* f.e. check whether existing feature is referenced
* <pre>
* kc.feature-enabled-<feature>:v1
* → (key, value) -> is key a feature? if not, fail
*
* @param validator validator with parameters (wildcardKey, value)
*/
public Builder<T> wildcardKeysValidator(BiConsumer<String, String> validator) {
addValidator((mapper, configValue) -> {
var wildcardMapper = (WildcardPropertyMapper<?>) mapper;
var key = wildcardMapper.extractWildcardValue(configValue.getName()).orElseThrow(() -> new PropertyException("Cannot determine feature name."));
validator.accept(key, configValue.getValue());
});
return this;
}

public PropertyMapper<T> build() {
if (paramLabel == null && Boolean.class.equals(option.getType())) {
paramLabel = Boolean.TRUE + "|" + Boolean.FALSE;
Expand Down
Loading
Loading