Redirect requests from outdated theme version to the current theme version

Closes #39723

Signed-off-by: Alexander Schwartz <aschwart@redhat.com>
This commit is contained in:
Alexander Schwartz 2025-06-11 11:13:55 +02:00 committed by GitHub
parent ad92af3c58
commit 4af3d7cc9d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 248 additions and 14 deletions

View File

@ -123,11 +123,14 @@ It can be useful for instance if you redeployed custom providers or custom theme
Theme properties are set in the file `<THEME TYPE>/theme.properties` in the theme directory.
* parent - Parent theme to extend
* import - Import resources from another theme
* common - Override the common resource path. The default value is `common/keycloak` when not specified. This value would be used as value of suffix of `${url.resourcesCommonPath}`, which is used typically in freemarker templates (prefix of `${url.resoucesCommonPath}` value is theme root uri).
* styles - Space-separated list of styles to include
* locales - Comma-separated list of supported locales
`parent`:: Parent theme to extend
`import`:: Import resources from another theme
`common`:: Override the common resource path. The default value is `common/keycloak` when not specified. This value would be used as value of suffix of `${url.resourcesCommonPath}`, which is used typically in freemarker templates (prefix of `${url.resoucesCommonPath}` value is theme root uri).
`styles`:: Space-separated list of styles to include
`locales`:: Comma-separated list of supported locales
`contentHashPattern`:: Regex pattern of a file path in the theme where files have a content hash as part of their file name.
A content hash is usually an abbreviated hash of the file's contents. The hash will change when the contents of the file have changed, and is usually created using the bundling process of the JavaScript application bundling.
When the preview feature `rolling-updates:v2` is enabled, this allows for a more seamless rolling upgrade.
There are a list of properties that can be used to change the css class used for certain element types. For a list of these properties look at the theme.properties
file in the corresponding type of the keycloak theme (`themes/keycloak/<THEME TYPE>/theme.properties`).

View File

@ -113,5 +113,30 @@ The `message` field explains why this strategy was chosen.
|===
[[operator-rolling-updates-for-patch-releases]]
== Rolling updates for patch releases
WARNING: This behavior is currently in an experimental mode, and it is not recommended for use in production.
It is possible to enable automatic rolling updates when upgrading to a newer patch version in the same `+major.minor+` release stream.
To enable this behavior, enable feature `rolling-updates:v2` as shown in the following example:
[source,yaml]
----
apiVersion: k8s.keycloak.org/v2alpha1
kind: Keycloak
metadata:
name: example-kc
spec:
features:
enabled:
- rolling-updates:v2
update:
strategy: Auto
----
Read more about rolling updates for patch releases in the <@links.server id="update-compatibility" anchor="rolling-updates-for-patch-releases" /> {section}.
</@tmpl.guide>

View File

@ -149,13 +149,27 @@ The feature `rolling-updates` is disabled.
WARNING: This behavior is currently in an experimental mode, and it is not recommended for use in production.
It is possible to configure the {project_name} compatibility command to allow rolling updates when updating from a version to a same patch version from the same `major.minor` release stream.
It is possible to configure the {project_name} compatibility command to allow rolling updates when upgrading to a newer patch version in the same `+major.minor+` release stream.
To enable this behavior for compatibility check command enable feature `rolling-updates:v2` as shown in the following example.
<@kc.updatecompatibility parameters="check --file=/path/to/file.json --features=rolling-updates:v2"/>
Note there is no change needed when generating metadata using `metadata` command.
Recommended Configuration:
* Enable sticky sessions in your loadbalancer to avoid users bouncing between different versions of {project_name} as this could result in users needing to refresh their Account Console and Admin UI multiple times while the upgrade is progressing.
Supported functionality during rolling updates:
* Users can log in and log out for OpenID Connect clients.
* OpenID Connect clients can perform all operations, for example, refreshing tokens and querying the user info endpoint.
Known limitations:
* If there have been changes to the Account Console or Admin UI in the patch release, and the user opened the Account Console or Admin UI before or during the upgrade, the user might see an error message and be asked to reload the application while navigating in browser during or after the upgrade.
== Further reading
The {project_name} Operator uses the functionality described above to determine if a rolling update is possible. See the <@links.operator id="rolling-updates" /> {section} and the `Auto` strategy for more information.

View File

@ -3,3 +3,4 @@ deprecatedMode=false
darkMode=true
kcDarkModeClass=pf-v5-theme-dark
contentHashPattern=assets/.*

View File

@ -2,3 +2,4 @@ parent=base
darkMode=true
kcDarkModeClass=pf-v5-theme-dark
contentHashPattern=assets/.*

View File

@ -30,7 +30,8 @@ import java.util.Properties;
*/
public interface Theme {
public static final String ACCOUNT_RESOURCE_PROVIDER_KEY = "accountResourceProvider";
String ACCOUNT_RESOURCE_PROVIDER_KEY = "accountResourceProvider";
String CONTENT_HASH_PATTERN = "contentHashPattern";
enum Type { LOGIN, ACCOUNT, ADMIN, EMAIL, WELCOME, COMMON };
@ -83,4 +84,22 @@ public interface Theme {
Properties getProperties() throws IOException;
/**
* Check if the given path contains a content hash.
* If a resource is requested from this path, and it has a content hash, this guarantees that if the file
* exists in two versions of the theme, it will contain the same contents.
* With this guarantee, a different version of Keycloak can return the same contents even if a caller asks for
* a different version of Keycloak.
*
* @param path path to check for a content hash
*/
default boolean hasContentHash(String path) throws IOException {
Object contentHashPattern = getProperties().get(CONTENT_HASH_PATTERN);
if (contentHashPattern != null) {
return path.matches(contentHashPattern.toString());
} else {
return false;
}
}
}

View File

@ -143,7 +143,7 @@ public class DefaultSecurityHeadersProvider implements SecurityHeadersProvider {
}
int status = responseContext.getStatus();
if (status == 201 || status == 204 ||
status == 301 || status == 302 || status == 303 || status == 307 || status == 308 ||
status == 301 || status == 302 || status == 303 || status == 304 || status == 307 || status == 308 ||
status == 400 || status == 401 || status == 403 || status == 404) {
return true;
}

View File

@ -17,12 +17,17 @@
package org.keycloak.services.resources;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.HeaderParam;
import jakarta.ws.rs.OPTIONS;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.CacheControl;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.UriBuilder;
import org.keycloak.common.Profile;
import org.keycloak.common.Version;
import org.keycloak.common.util.MimeTypeUtil;
import org.keycloak.encoding.ResourceEncodingHelper;
@ -46,6 +51,7 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Properties;
import java.util.Set;
@ -76,15 +82,48 @@ public class ThemeResource {
*/
@GET
@Path("/{version}/{themeType}/{themeName}/{path:.*}")
public Response getResource(@PathParam("version") String version, @PathParam("themeType") String themeType, @PathParam("themeName") String themeName, @PathParam("path") String path) {
public Response getResource(@PathParam("version") String version, @PathParam("themeType") String themeType, @PathParam("themeName") String themeName, @PathParam("path") String path, @HeaderParam(HttpHeaders.IF_NONE_MATCH) String etag) {
final Optional<Theme.Type> type = getThemeType(themeType);
if (!version.equals(Version.RESOURCES_VERSION) || type.isEmpty()) {
if (!version.equals(Version.RESOURCES_VERSION) && !Profile.isFeatureEnabled(Profile.Feature.ROLLING_UPDATES_V2)) {
return Response.status(Response.Status.NOT_FOUND).build();
}
if (type.isEmpty()) {
return Response.status(Response.Status.NOT_FOUND).build();
}
try {
String contentType = MimeTypeUtil.getContentType(path);
Theme theme = session.theme().getTheme(themeName, type.get());
boolean hasContentHash = theme.hasContentHash(path);
if (Profile.isFeatureEnabled(Profile.Feature.ROLLING_UPDATES_V2)) {
if (!version.equals(Version.RESOURCES_VERSION) && !hasContentHash) {
// If it is not the right version, and it does not have a content hash, redirect.
// If it is not the right version, but it has a content hash, continue to see if it exists.
// The redirect will lead the browser to a resource that it then (when retrieved successfully) can cache again.
// This assumes that it is better to try to some content even if it is outdated or too new, instead of returning a 404.
// This should usually work for images, CSS or (simple) JavaScript referenced in the login theme that needs to be
// loaded while the rolling restart is progressing.
return Response.temporaryRedirect(
UriBuilder.fromResource(ThemeResource.class)
.path("/{version}/{themeType}/{themeName}/{path}")
// The 'path' can contain slashes, so encoding of slashes is set to false
.build(new Object[]{Version.RESOURCES_VERSION, themeType, themeName, path}, false)
).build();
}
if (hasContentHash && Objects.equals(etag, Version.RESOURCES_VERSION)) {
// We delivered this resource earlier, and its etag matches the resource version, so it has not changed
return Response.notModified()
.header(HttpHeaders.ETAG, Version.RESOURCES_VERSION)
.cacheControl(CacheControlUtil.getDefaultCacheControl()).build();
}
}
ResourceEncodingProvider encodingProvider = session.theme().isCacheEnabled() ? ResourceEncodingHelper.getResourceEncodingProvider(session, contentType) : null;
InputStream resource;
@ -96,6 +135,13 @@ public class ThemeResource {
if (resource != null) {
Response.ResponseBuilder rb = Response.ok(resource).type(contentType).cacheControl(CacheControlUtil.getDefaultCacheControl());
if (Profile.isFeatureEnabled(Profile.Feature.ROLLING_UPDATES_V2)){
if (hasContentHash) {
// All items with a content hash receive an etag, so we can then provide a not-modified response later
rb.header(HttpHeaders.ETAG, Version.RESOURCES_VERSION);
}
}
if (encodingProvider != null) {
rb.encoding(encodingProvider.getEncoding());
}

View File

@ -162,17 +162,27 @@ public class DefaultThemeManager implements ThemeManager {
private static class ExtendingTheme implements Theme {
private List<Theme> themes;
private Set<ThemeResourceProvider> themeResourceProviders;
private final List<Theme> themes;
private final Set<ThemeResourceProvider> themeResourceProviders;
private Properties properties;
private ConcurrentHashMap<String, ConcurrentHashMap<Locale, Map<Locale, Properties>>> messages =
private final ConcurrentHashMap<String, ConcurrentHashMap<Locale, Map<Locale, Properties>>> messages =
new ConcurrentHashMap<>();
private Pattern compiledContentHashPattern;
public ExtendingTheme(List<Theme> themes, Set<ThemeResourceProvider> themeResourceProviders) {
this.themes = themes;
this.themeResourceProviders = themeResourceProviders;
try {
Object contentHashPattern = getProperties().get(CONTENT_HASH_PATTERN);
if (contentHashPattern != null) {
compiledContentHashPattern = Pattern.compile(contentHashPattern.toString());
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
@Override
@ -250,6 +260,11 @@ public class DefaultThemeManager implements ThemeManager {
return LocaleUtil.enhancePropertiesWithRealmLocalizationTexts(realm, locale, messagesByLocale);
}
@Override
public boolean hasContentHash(String path) throws IOException {
return compiledContentHashPattern != null && compiledContentHashPattern.matcher(path).matches();
}
private Map<Locale, Properties> getMessagesByLocale(String baseBundlename, Locale locale) throws IOException {
if (messages.get(baseBundlename) == null || messages.get(baseBundlename).get(locale) == null) {
Locale parent = getParent(locale);

View File

@ -117,7 +117,7 @@ public class KeycloakTestingClient implements AutoCloseable {
String featureString;
if (Profile.getFeatureVersions(feature.getUnversionedKey()).size() > 1) {
featureString = feature.getVersionedKey();
Profile.Feature featureVersionHighestPriority = Profile.getFeatureVersions(feature.getKey()).iterator().next();
Profile.Feature featureVersionHighestPriority = Profile.getFeatureVersions(feature.getUnversionedKey()).iterator().next();
if (featureVersionHighestPriority.getType().equals(Profile.Feature.Type.DEFAULT)) {
enableFeature(featureVersionHighestPriority);
}

View File

@ -5,12 +5,18 @@ 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.CoreMatchers;
import org.hamcrest.MatcherAssert;
import org.junit.Assert;
import org.junit.Test;
import org.keycloak.common.Profile;
import org.keycloak.common.Version;
import org.keycloak.platform.Platform;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.testsuite.AbstractTestRealmKeycloakTest;
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
import org.keycloak.testsuite.arquillian.annotation.EnableFeatures;
import org.keycloak.theme.Theme;
import java.io.File;
@ -19,6 +25,8 @@ import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Paths;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.zip.GZIPInputStream;
import static org.junit.Assert.assertEquals;
@ -134,6 +142,57 @@ public class ThemeResourceProviderTest extends AbstractTestRealmKeycloakTest {
assertNotFound(suiteContext.getAuthServerInfo().getContextRoot().toString() + "/auth/resources/" + resourcesVersion + "/invalid-theme-type/keycloak/css/welcome.css");
}
@Test
@EnableFeatures(@EnableFeature(Profile.Feature.ROLLING_UPDATES_V2))
public void fetchStaticResourceShouldRedirectOnUnknownVersion() throws IOException {
final String resourcesVersion = testingClient.server().fetch(session -> Version.RESOURCES_VERSION, String.class);
assertFound(suiteContext.getAuthServerInfo().getContextRoot().toString() + "/auth/resources/" + resourcesVersion + "/login/keycloak.v2/css/styles.css");
assertRedirect(suiteContext.getAuthServerInfo().getContextRoot().toString() + "/auth/resources/" + "unknown" + "/login/keycloak.v2/css/styles.css");
}
@Test
@EnableFeatures(@EnableFeature(Profile.Feature.ROLLING_UPDATES_V2))
public void fetchResourceWithContentHashShouldReturnContentIfVersionIsUnknown() throws IOException {
final String resourcesVersion = testingClient.server().fetch(session -> Version.RESOURCES_VERSION, String.class);
String resource = getResourceWithContentHash();
// The original resource should be accessible.
assertNoRedirect(suiteContext.getAuthServerInfo().getContextRoot().toString() + resource);
// The unknown resource should be accessible without a redirect.
assertNoRedirect(suiteContext.getAuthServerInfo().getContextRoot().toString() + resource.replaceAll(Pattern.quote(resourcesVersion), "unknown"));
}
@Test
@EnableFeatures(@EnableFeature(Profile.Feature.ROLLING_UPDATES_V2))
public void fetchResourceWithContentHashShouldHonorEtag() throws IOException {
String resource = getResourceWithContentHash();
// The first fetch should return an etag
String etag = fetchEtag(suiteContext.getAuthServerInfo().getContextRoot().toString() + resource);
// The second fetch with the etag should return not modified
assertEtagHonored(suiteContext.getAuthServerInfo().getContextRoot().toString() + resource, etag);
}
private String getResourceWithContentHash() throws IOException {
try (CloseableHttpClient httpClient = HttpClientBuilder.create().build()) {
HttpGet get = new HttpGet(suiteContext.getAuthServerInfo().getContextRoot().toString() + "/auth/admin/" + TEST_REALM_NAME + "/console/");
try (CloseableHttpResponse response = httpClient.execute(get)) {
assertEquals(200, response.getStatusLine().getStatusCode());
String body = EntityUtils.toString(response.getEntity());
Matcher matcher = Pattern.compile("<link rel=\"stylesheet\" href=\"([^\"]*)\">").matcher(body);
if (matcher.find()) {
return matcher.group(1);
} else {
throw new AssertionError("unable to find resource in body");
}
}
}
}
private void assertNotFound(String url) throws IOException {
try (CloseableHttpClient httpClient = HttpClientBuilder.create().build()) {
HttpGet get = new HttpGet(url);
@ -143,6 +202,57 @@ public class ThemeResourceProviderTest extends AbstractTestRealmKeycloakTest {
}
}
private void assertFound(String url) throws IOException {
try (CloseableHttpClient httpClient = HttpClientBuilder.create().build()) {
HttpGet get = new HttpGet(url);
CloseableHttpResponse response = httpClient.execute(get);
MatcherAssert.assertThat(response.getStatusLine().getStatusCode(), CoreMatchers.equalTo(200));
}
}
private String fetchEtag(String url) throws IOException {
try (CloseableHttpClient httpClient = HttpClientBuilder.create().build()) {
HttpGet get = new HttpGet(url);
CloseableHttpResponse response = httpClient.execute(get);
MatcherAssert.assertThat(response.getStatusLine().getStatusCode(), CoreMatchers.equalTo(200));
return response.getFirstHeader("ETag").getValue();
}
}
private void assertRedirect(String url) throws IOException {
try (CloseableHttpClient httpClient = HttpClientBuilder.create().disableRedirectHandling().build()) {
HttpGet get = new HttpGet(url);
CloseableHttpResponse response = httpClient.execute(get);
MatcherAssert.assertThat(response.getStatusLine().getStatusCode(), CoreMatchers.equalTo(307));
assertFound(url);
}
}
private void assertEtagHonored(String url, String etag) throws IOException {
try (CloseableHttpClient httpClient = HttpClientBuilder.create().disableRedirectHandling().build()) {
HttpGet get = new HttpGet(url);
get.addHeader("If-None-Match", etag);
CloseableHttpResponse response = httpClient.execute(get);
MatcherAssert.assertThat(response.getStatusLine().getStatusCode(), CoreMatchers.equalTo(304));
}
}
private void assertNoRedirect(String url) throws IOException {
try (CloseableHttpClient httpClient = HttpClientBuilder.create().disableRedirectHandling().build()) {
HttpGet get = new HttpGet(url);
CloseableHttpResponse response = httpClient.execute(get);
MatcherAssert.assertThat(response.getStatusLine().getStatusCode(), CoreMatchers.equalTo(200));
}
}
private void assertEncoded(String url, String expectedContent) throws IOException {
try (CloseableHttpClient httpClient = HttpClientBuilder.create().disableContentCompression().build()) {
HttpGet get = new HttpGet(url);