Skip to content

Commit

Permalink
Cookie Provider (keycloak#26499)
Browse files Browse the repository at this point in the history
Closes keycloak#26500

Signed-off-by: stianst <[email protected]>
  • Loading branch information
stianst authored Jan 26, 2024
1 parent 3655268 commit bc3c279
Show file tree
Hide file tree
Showing 24 changed files with 376 additions and 98 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ public class ServerCookie implements Serializable {
private static final String tspecials2 = "()<>@,;:\\\"/[]?={} \t";

public enum SameSiteAttributeValue {
NONE("None"); // we currently support only SameSite=None; this might change in the future
NONE("None"),
LAX("Lax"),
STRICT("Strict");

private final String specValue;
SameSiteAttributeValue(String specValue) {
Expand Down
5 changes: 5 additions & 0 deletions docs/documentation/release_notes/topics/24_0_0.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -170,3 +170,8 @@ link:{upgradingguide_link}[{upgradingguide_name}].
= Authorization Policy

In previous versions of Keycloak when the last member of a User, Group or Client policy was deleted then that policy would also be deleted. Unfortunately this could lead to an escalation of privileges if the policy was used in an aggregate policy. To avoid privilege escalation the effect policies are no longer deleted and an administrator will need to update those policies.

= Updates to cookies

Cookie handling code has been refactored and improved, including a new Cookie Provider. This provides better consistency
for cookies handled by Keycloak, and the ability to introduce configuration options around cookies if needed.
12 changes: 12 additions & 0 deletions docs/documentation/upgrading/topics/keycloak/changes-24_0_0.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -285,3 +285,15 @@ After removal of the Map Store the following modules were renamed:
* `org.keycloak:keycloak-model-legacy` to `org.keycloak:keycloak-model-storage`
* `org.keycloak:keycloak-model-legacy-private` to `org.keycloak:keycloak-model-storage-private`
* `org.keycloak:keycloak-model-legacy-services` to `org.keycloak:keycloak-model-storage-services`

= Updates to cookies

As part of refactoring cookie handling in Keycloak there are some changes to how cookies are set:

* All cookies will now have the secure attribute set if the request is through a secure context
* KEYCLOAK_LOCALE and WELCOME_STATE_CHECKER cookies now set SameSite=Strict

For custom extensions there may be some changes needed:

* LocaleSelectorProvider.KEYCLOAK_LOCALE is deprecated as cookies are now managed through the CookieProvider
* HttpResponse.setWriteCookiesOnTransactionComplete has been removed
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package org.keycloak.cookie;

public interface CookieMaxAge {

int EXPIRED = 0;

int SESSION = -1;

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package org.keycloak.cookie;

enum CookiePath {
REALM,
REQUEST
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package org.keycloak.cookie;

import org.keycloak.provider.Provider;

public interface CookieProvider extends Provider {

void set(CookieType cookieType, String value);

void set(CookieType cookieType, String value, int maxAge);

String get(CookieType cookieType);

void expire(CookieType cookieType);

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package org.keycloak.cookie;

import org.keycloak.provider.ProviderFactory;

public interface CookieProviderFactory extends ProviderFactory<CookieProvider> {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package org.keycloak.cookie;

import org.keycloak.common.util.ServerCookie;

enum CookieScope {
// Internal cookies are only available for direct requests to Keycloak
INTERNAL(ServerCookie.SameSiteAttributeValue.STRICT, true),

// Federation cookies are available after redirect from applications, and are also available in an iframe context
// unless the browser blocks third-party cookies
FEDERATION(ServerCookie.SameSiteAttributeValue.NONE, true),

// Federation cookies that are also available from JavaScript
FEDERATION_JS(ServerCookie.SameSiteAttributeValue.NONE, false),

// Legacy cookies do not set the SameSite attribute and will default to SameSite=Lax in modern browsers
@Deprecated
LEGACY(null, true),

// Legacy cookies that are also available from JavaScript
@Deprecated
LEGACY_JS(null, false);

private final ServerCookie.SameSiteAttributeValue sameSite;
private final boolean httpOnly;

CookieScope(ServerCookie.SameSiteAttributeValue sameSite, boolean httpOnly) {
this.sameSite = sameSite;
this.httpOnly = httpOnly;
}

public ServerCookie.SameSiteAttributeValue getSameSite() {
return sameSite;
}

public boolean isHttpOnly() {
return httpOnly;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package org.keycloak.cookie;

import org.keycloak.provider.Provider;
import org.keycloak.provider.ProviderFactory;
import org.keycloak.provider.Spi;

public class CookieSpi implements Spi {
@Override
public boolean isInternal() {
return true;
}

@Override
public String getName() {
return "cookie";
}

@Override
public Class<? extends Provider> getProviderClass() {
return CookieProvider.class;
}

@Override
public Class<? extends ProviderFactory> getProviderFactoryClass() {
return CookieProviderFactory.class;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package org.keycloak.cookie;

public enum CookieType {

KEYCLOAK_LOCALE(CookiePath.REALM, CookieScope.INTERNAL, CookieMaxAge.SESSION),
WELCOME_STATE_CHECKER(CookiePath.REQUEST, CookieScope.INTERNAL, 300),
KC_AUTH_STATE(CookiePath.REALM, CookieScope.LEGACY_JS), // TODO Change CookieScope
KC_RESTART(CookiePath.REALM, CookieScope.LEGACY, CookieMaxAge.SESSION); // TODO Change CookieScope

private final CookiePath path;
private final CookieScope scope;

private final Integer defaultMaxAge;

CookieType(CookiePath path, CookieScope scope) {
this.path = path;
this.scope = scope;
this.defaultMaxAge = null;
}

CookieType(CookiePath path, CookieScope scope, int defaultMaxAge) {
this.path = path;
this.scope = scope;
this.defaultMaxAge = defaultMaxAge;
}

public CookiePath getPath() {
return path;
}

public CookieScope getScope() {
return scope;
}

public Integer getDefaultMaxAge() {
return defaultMaxAge;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -93,3 +93,4 @@ org.keycloak.services.clientpolicy.ClientPolicyManagerSpi
org.keycloak.userprofile.UserProfileSpi
org.keycloak.device.DeviceRepresentationSpi
org.keycloak.health.LoadBalancerCheckSpi
org.keycloak.cookie.CookieSpi
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

public interface LocaleSelectorProvider extends Provider {

@Deprecated(since = "24.0.0", forRemoval = true)
String LOCALE_COOKIE = "KEYCLOAK_LOCALE";
String KC_LOCALE_PARAM = "kc_locale";

Expand Down
126 changes: 126 additions & 0 deletions services/src/main/java/org/keycloak/cookie/DefaultCookieProvider.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package org.keycloak.cookie;

import jakarta.ws.rs.core.Cookie;
import org.keycloak.common.util.ServerCookie;
import org.keycloak.http.HttpCookie;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.services.resources.RealmsResource;
import org.keycloak.urls.UrlType;

import java.net.URI;

public class DefaultCookieProvider implements CookieProvider {

private static final String LEGACY_SUFFIX = "_LEGACY";

private KeycloakSession session;

private boolean legacyCookiesEnabled;

public DefaultCookieProvider(KeycloakSession session, boolean legacyCookiesEnabled) {
this.session = session;
this.legacyCookiesEnabled = legacyCookiesEnabled;
}

@Override
public void set(CookieType cookieType, String value) {
if (cookieType.getDefaultMaxAge() == null) {
throw new IllegalArgumentException(cookieType + " has no default max-age");
}

set(cookieType, value, cookieType.getDefaultMaxAge());
}

@Override
public void set(CookieType cookieType, String value, int maxAge) {
String name = cookieType.name();
ServerCookie.SameSiteAttributeValue sameSite = cookieType.getScope().getSameSite();
boolean secure = resolveSecure(sameSite);
String path = resolvePath(cookieType);
boolean httpOnly = cookieType.getScope().isHttpOnly();

HttpCookie newCookie = new HttpCookie(1, name, value, path, null, null, maxAge, secure, httpOnly, sameSite);
session.getContext().getHttpResponse().setCookieIfAbsent(newCookie);

if (legacyCookiesEnabled) {
if (ServerCookie.SameSiteAttributeValue.NONE.equals(sameSite)) {
String legacyName = name + LEGACY_SUFFIX;
HttpCookie legacyCookie = new HttpCookie(1, legacyName, value, path, null, null, maxAge, secure, httpOnly, null);
session.getContext().getHttpResponse().setCookieIfAbsent(legacyCookie);
}
} else {
expireLegacy(cookieType);
}
}

@Override
public String get(CookieType cookieType) {
Cookie cookie = session.getContext().getRequestHeaders().getCookies().get(cookieType.name());
return cookie != null ? cookie.getValue() : null;
}

@Override
public void expire(CookieType cookieType) {
Cookie cookie = session.getContext().getRequestHeaders().getCookies().get(cookieType.name());
expire(cookie, cookieType);

expireLegacy(cookieType);
}

private void expireLegacy(CookieType cookieType) {
String legacyName = cookieType.name() + LEGACY_SUFFIX;
Cookie legacyCookie = session.getContext().getRequestHeaders().getCookies().get(legacyName);
expire(legacyCookie, cookieType);
}

private void expire(Cookie cookie, CookieType cookieType) {
if (cookie != null) {
String path = resolvePath(cookieType);
HttpCookie newCookie = new HttpCookie(1, cookie.getName(), "", path, null, null, 0, false, false, null);
session.getContext().getHttpResponse().setCookieIfAbsent(newCookie);
}
}

@Override
public void close() {
}

private String resolvePath(CookieType cookieType) {
switch (cookieType.getPath()) {
case REALM:
return RealmsResource.realmBaseUrl(session.getContext().getUri()).path("/").build(session.getContext().getRealm().getName()).getRawPath();
case REQUEST:
return session.getContext().getUri().getRequestUri().getRawPath();
default:
throw new IllegalArgumentException("Unsupported enum value " + cookieType.getPath().name());
}
}

private boolean resolveSecure(ServerCookie.SameSiteAttributeValue sameSite) {
URI requestUri = session.getContext().getUri().getRequestUri();

// SameSite=none requires secure context
if (ServerCookie.SameSiteAttributeValue.NONE.equals(sameSite)) {
return true;
}

RealmModel realm = session.getContext().getRealm();
if (realm != null && realm.getSslRequired().isRequired(requestUri.getHost())) {
return true;
}

if ("https".equals(requestUri.getScheme())) {
return true;
}

// Browsers consider 127.0.0.1, localhost and *.localhost as secure contexts
String frontendHostname = session.getContext().getUri(UrlType.FRONTEND).getRequestUri().getHost();
if (frontendHostname.equals("127.0.0.1") || frontendHostname.equals("localhost") || frontendHostname.endsWith(".localhost")) {
return true;
}

return false;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package org.keycloak.cookie;

import org.keycloak.Config;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;

public class DefaultCookieProviderFactory implements CookieProviderFactory {

private boolean legacyCookies;

@Override
public CookieProvider create(KeycloakSession session) {
return new DefaultCookieProvider(session, legacyCookies);
}

@Override
public void init(Config.Scope config) {
legacyCookies = config.getBoolean("legacyCookies", false);
}

@Override
public void postInit(KeycloakSessionFactory factory) {
}

@Override
public void close() {
}

@Override
public String getId() {
return "default";
}

}
Loading

0 comments on commit bc3c279

Please sign in to comment.