Use code from RestEasy to create and set cookies (#26558)
Closes #26557 Signed-off-by: stianst <stianst@gmail.com>
This commit is contained in:
parent
acd9def8aa
commit
c4b1fd092a
17 changed files with 304 additions and 139 deletions
|
@ -27,7 +27,11 @@ import java.util.TimeZone;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Server-side cookie representation. borrowed from Tomcat.
|
* Server-side cookie representation. borrowed from Tomcat.
|
||||||
|
*
|
||||||
|
* @deprecated Should not be used on the Keycloak server-side, or in extensions. Will be removed when no longer used by
|
||||||
|
* adapters
|
||||||
*/
|
*/
|
||||||
|
@Deprecated
|
||||||
public class ServerCookie implements Serializable {
|
public class ServerCookie implements Serializable {
|
||||||
private static final String tspecials = ",; ";
|
private static final String tspecials = ",; ";
|
||||||
private static final String tspecials2 = "()<>@,;:\\\"/[]?={} \t";
|
private static final String tspecials2 = "()<>@,;:\\\"/[]?={} \t";
|
||||||
|
|
|
@ -306,9 +306,11 @@ and `org.keycloak:keycloak-model-legacy` module was deprecated and will be remov
|
||||||
As part of refactoring cookie handling in Keycloak there are some changes to how cookies are set:
|
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
|
* 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
|
* `WELCOME_STATE_CHECKER` cookies now set `SameSite=Strict`
|
||||||
|
|
||||||
For custom extensions there may be some changes needed:
|
For custom extensions there may be some changes needed:
|
||||||
|
|
||||||
* LocaleSelectorProvider.KEYCLOAK_LOCALE is deprecated as cookies are now managed through the CookieProvider
|
* `LocaleSelectorProvider.KEYCLOAK_LOCALE` is deprecated as cookies are now managed through the CookieProvider
|
||||||
* HttpResponse.setWriteCookiesOnTransactionComplete has been removed
|
* `HttpResponse.setWriteCookiesOnTransactionComplete` has been removed
|
||||||
|
* `HttpCookie` is deprecated, please use `NewCookie.Builder` instead
|
||||||
|
* `ServerCookie` is deprecated, please use `NewCookie.Builder` instead
|
||||||
|
|
2
pom.xml
2
pom.xml
|
@ -112,7 +112,7 @@
|
||||||
<jboss.spec.javax.servlet.jsp.jboss-jsp-api_2.3_spec.version>2.0.0.Final</jboss.spec.javax.servlet.jsp.jboss-jsp-api_2.3_spec.version>
|
<jboss.spec.javax.servlet.jsp.jboss-jsp-api_2.3_spec.version>2.0.0.Final</jboss.spec.javax.servlet.jsp.jboss-jsp-api_2.3_spec.version>
|
||||||
<log4j.version>1.2.17</log4j.version>
|
<log4j.version>1.2.17</log4j.version>
|
||||||
<resteasy-legacy.version>4.7.7.Final</resteasy-legacy.version>
|
<resteasy-legacy.version>4.7.7.Final</resteasy-legacy.version>
|
||||||
<resteasy.version>6.2.4.Final</resteasy.version>
|
<resteasy.version>6.2.7.Final</resteasy.version>
|
||||||
<resteasy.undertow.version>${resteasy.version}</resteasy.undertow.version>
|
<resteasy.undertow.version>${resteasy.version}</resteasy.undertow.version>
|
||||||
<owasp.html.sanitizer.version>20220608.1</owasp.html.sanitizer.version>
|
<owasp.html.sanitizer.version>20220608.1</owasp.html.sanitizer.version>
|
||||||
<slf4j.version>2.0.6</slf4j.version>
|
<slf4j.version>2.0.6</slf4j.version>
|
||||||
|
|
|
@ -18,9 +18,10 @@
|
||||||
package org.keycloak.quarkus.runtime.integration.resteasy;
|
package org.keycloak.quarkus.runtime.integration.resteasy;
|
||||||
|
|
||||||
import jakarta.ws.rs.core.HttpHeaders;
|
import jakarta.ws.rs.core.HttpHeaders;
|
||||||
|
import jakarta.ws.rs.core.NewCookie;
|
||||||
|
import jakarta.ws.rs.ext.RuntimeDelegate;
|
||||||
import org.jboss.resteasy.reactive.server.core.ResteasyReactiveRequestContext;
|
import org.jboss.resteasy.reactive.server.core.ResteasyReactiveRequestContext;
|
||||||
import org.jboss.resteasy.reactive.server.vertx.VertxResteasyReactiveRequestContext;
|
import org.jboss.resteasy.reactive.server.vertx.VertxResteasyReactiveRequestContext;
|
||||||
import org.keycloak.http.HttpCookie;
|
|
||||||
import org.keycloak.http.HttpResponse;
|
import org.keycloak.http.HttpResponse;
|
||||||
|
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
|
@ -28,9 +29,11 @@ import java.util.Set;
|
||||||
|
|
||||||
public final class QuarkusHttpResponse implements HttpResponse {
|
public final class QuarkusHttpResponse implements HttpResponse {
|
||||||
|
|
||||||
|
private static final RuntimeDelegate.HeaderDelegate<NewCookie> NEW_COOKIE_HEADER_DELEGATE = RuntimeDelegate.getInstance().createHeaderDelegate(NewCookie.class);
|
||||||
|
|
||||||
private final ResteasyReactiveRequestContext requestContext;
|
private final ResteasyReactiveRequestContext requestContext;
|
||||||
|
|
||||||
private Set<HttpCookie> cookies;
|
private Set<NewCookie> newCookies;
|
||||||
|
|
||||||
public QuarkusHttpResponse(ResteasyReactiveRequestContext requestContext) {
|
public QuarkusHttpResponse(ResteasyReactiveRequestContext requestContext) {
|
||||||
this.requestContext = requestContext;
|
this.requestContext = requestContext;
|
||||||
|
@ -58,18 +61,18 @@ public final class QuarkusHttpResponse implements HttpResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void setCookieIfAbsent(HttpCookie cookie) {
|
public void setCookieIfAbsent(NewCookie newCookie) {
|
||||||
if (cookie == null) {
|
if (newCookie == null) {
|
||||||
throw new IllegalArgumentException("Cookie is null");
|
throw new IllegalArgumentException("Cookie is null");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cookies == null) {
|
if (newCookies == null) {
|
||||||
cookies = new HashSet<>();
|
newCookies = new HashSet<>();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cookies.add(cookie)) {
|
if (newCookies.add(newCookie)) {
|
||||||
addHeader(HttpHeaders.SET_COOKIE, cookie.toHeaderValue());
|
String headerValue = NEW_COOKIE_HEADER_DELEGATE.toString(newCookie);
|
||||||
|
addHeader(HttpHeaders.SET_COOKIE, headerValue);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,17 +1,17 @@
|
||||||
package org.keycloak.cookie;
|
package org.keycloak.cookie;
|
||||||
|
|
||||||
import org.keycloak.common.util.ServerCookie;
|
import jakarta.ws.rs.core.NewCookie;
|
||||||
|
|
||||||
public enum CookieScope {
|
public enum CookieScope {
|
||||||
// Internal cookies are only available for direct requests to Keycloak
|
// Internal cookies are only available for direct requests to Keycloak
|
||||||
INTERNAL(ServerCookie.SameSiteAttributeValue.STRICT, true),
|
INTERNAL(NewCookie.SameSite.STRICT, true),
|
||||||
|
|
||||||
// Federation cookies are available after redirect from applications, and are also available in an iframe context
|
// Federation cookies are available after redirect from applications, and are also available in an iframe context
|
||||||
// unless the browser blocks third-party cookies
|
// unless the browser blocks third-party cookies
|
||||||
FEDERATION(ServerCookie.SameSiteAttributeValue.NONE, true),
|
FEDERATION(NewCookie.SameSite.NONE, true),
|
||||||
|
|
||||||
// Federation cookies that are also available from JavaScript
|
// Federation cookies that are also available from JavaScript
|
||||||
FEDERATION_JS(ServerCookie.SameSiteAttributeValue.NONE, false),
|
FEDERATION_JS(NewCookie.SameSite.NONE, false),
|
||||||
|
|
||||||
// Legacy cookies do not set the SameSite attribute and will default to SameSite=Lax in modern browsers
|
// Legacy cookies do not set the SameSite attribute and will default to SameSite=Lax in modern browsers
|
||||||
@Deprecated
|
@Deprecated
|
||||||
|
@ -21,15 +21,15 @@ public enum CookieScope {
|
||||||
@Deprecated
|
@Deprecated
|
||||||
LEGACY_JS(null, false);
|
LEGACY_JS(null, false);
|
||||||
|
|
||||||
private final ServerCookie.SameSiteAttributeValue sameSite;
|
private final NewCookie.SameSite sameSite;
|
||||||
private final boolean httpOnly;
|
private final boolean httpOnly;
|
||||||
|
|
||||||
CookieScope(ServerCookie.SameSiteAttributeValue sameSite, boolean httpOnly) {
|
CookieScope(NewCookie.SameSite sameSite, boolean httpOnly) {
|
||||||
this.sameSite = sameSite;
|
this.sameSite = sameSite;
|
||||||
this.httpOnly = httpOnly;
|
this.httpOnly = httpOnly;
|
||||||
}
|
}
|
||||||
|
|
||||||
public ServerCookie.SameSiteAttributeValue getSameSite() {
|
public NewCookie.SameSite getSameSite() {
|
||||||
return sameSite;
|
return sameSite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -17,33 +17,36 @@
|
||||||
|
|
||||||
package org.keycloak.http;
|
package org.keycloak.http;
|
||||||
|
|
||||||
import org.keycloak.common.util.ServerCookie;
|
import jakarta.ws.rs.core.NewCookie;
|
||||||
|
import jakarta.ws.rs.ext.RuntimeDelegate;
|
||||||
import org.keycloak.common.util.ServerCookie.SameSiteAttributeValue;
|
import org.keycloak.common.util.ServerCookie.SameSiteAttributeValue;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An extension of {@link javax.ws.rs.core.Cookie} in order to support additional
|
* An extension of {@link javax.ws.rs.core.Cookie} in order to support additional
|
||||||
* fields and behavior.
|
* fields and behavior.
|
||||||
|
*
|
||||||
|
* @deprecated This class will be removed in the future. Please use {@link jakarta.ws.rs.core.NewCookie.Builder}
|
||||||
*/
|
*/
|
||||||
public final class HttpCookie extends jakarta.ws.rs.core.Cookie {
|
@Deprecated(since = "24.0.0", forRemoval = true)
|
||||||
|
public final class HttpCookie extends NewCookie {
|
||||||
private final String comment;
|
|
||||||
private final int maxAge;
|
|
||||||
private final boolean secure;
|
|
||||||
private final boolean httpOnly;
|
|
||||||
private final SameSiteAttributeValue sameSite;
|
|
||||||
|
|
||||||
public HttpCookie(int version, String name, String value, String path, String domain, String comment, int maxAge, boolean secure, boolean httpOnly, SameSiteAttributeValue sameSite) {
|
public HttpCookie(int version, String name, String value, String path, String domain, String comment, int maxAge, boolean secure, boolean httpOnly, SameSiteAttributeValue sameSite) {
|
||||||
super(name, value, path, domain, version);
|
super(name, value, path, domain, version, comment, maxAge, null, secure, httpOnly, convertSameSite(sameSite));
|
||||||
this.comment = comment;
|
}
|
||||||
this.maxAge = maxAge;
|
|
||||||
this.secure = secure;
|
private static SameSite convertSameSite(SameSiteAttributeValue sameSiteAttributeValue) {
|
||||||
this.httpOnly = httpOnly;
|
if (sameSiteAttributeValue == null) {
|
||||||
this.sameSite = sameSite;
|
return null;
|
||||||
|
}
|
||||||
|
switch (sameSiteAttributeValue) {
|
||||||
|
case NONE: return SameSite.NONE;
|
||||||
|
case LAX: return SameSite.LAX;
|
||||||
|
case STRICT: return SameSite.STRICT;
|
||||||
|
}
|
||||||
|
throw new IllegalArgumentException("Unknown SameSite value " + sameSiteAttributeValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
public String toHeaderValue() {
|
public String toHeaderValue() {
|
||||||
StringBuilder cookieBuf = new StringBuilder();
|
return RuntimeDelegate.getInstance().createHeaderDelegate(NewCookie.class).toString(this);
|
||||||
ServerCookie.appendCookieValue(cookieBuf, getVersion(), getName(), getValue(), getPath(), getDomain(), comment, maxAge, secure, httpOnly, sameSite);
|
|
||||||
return cookieBuf.toString();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,6 +17,8 @@
|
||||||
|
|
||||||
package org.keycloak.http;
|
package org.keycloak.http;
|
||||||
|
|
||||||
|
import jakarta.ws.rs.core.NewCookie;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* <p>Represents an out coming HTTP response.
|
* <p>Represents an out coming HTTP response.
|
||||||
*
|
*
|
||||||
|
@ -56,6 +58,17 @@ public interface HttpResponse {
|
||||||
*
|
*
|
||||||
* @param cookie the cookie
|
* @param cookie the cookie
|
||||||
*/
|
*/
|
||||||
void setCookieIfAbsent(HttpCookie cookie);
|
void setCookieIfAbsent(NewCookie cookie);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets a new cookie only if not yet set.
|
||||||
|
* @deprecated This method will be removed in the future. Please use {@link jakarta.ws.rs.core.NewCookie.Builder}
|
||||||
|
*
|
||||||
|
* @param cookie the cookie
|
||||||
|
*/
|
||||||
|
@Deprecated(since = "24.0.0", forRemoval = true)
|
||||||
|
default void setCookieIfAbsent(HttpCookie cookie) {
|
||||||
|
setCookieIfAbsent((NewCookie) cookie);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,34 @@
|
||||||
|
package org.keycloak.cookie;
|
||||||
|
|
||||||
|
import org.keycloak.models.KeycloakContext;
|
||||||
|
import org.keycloak.services.resources.RealmsResource;
|
||||||
|
|
||||||
|
class CookiePathResolver {
|
||||||
|
|
||||||
|
private final KeycloakContext context;
|
||||||
|
private String realmPath;
|
||||||
|
|
||||||
|
private String requestPath;
|
||||||
|
|
||||||
|
CookiePathResolver(KeycloakContext context) {
|
||||||
|
this.context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
String resolvePath(CookieType cookieType) {
|
||||||
|
switch (cookieType.getPath()) {
|
||||||
|
case REALM:
|
||||||
|
if (realmPath == null) {
|
||||||
|
realmPath = RealmsResource.realmBaseUrl(context.getUri()).path("/").build(context.getRealm().getName()).getRawPath();
|
||||||
|
}
|
||||||
|
return realmPath;
|
||||||
|
case REQUEST:
|
||||||
|
if (requestPath == null) {
|
||||||
|
requestPath = context.getUri().getRequestUri().getRawPath();
|
||||||
|
}
|
||||||
|
return requestPath;
|
||||||
|
default:
|
||||||
|
throw new IllegalArgumentException("Unsupported enum value " + cookieType.getPath().name());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,39 @@
|
||||||
|
package org.keycloak.cookie;
|
||||||
|
|
||||||
|
import jakarta.ws.rs.core.NewCookie;
|
||||||
|
import org.keycloak.models.KeycloakContext;
|
||||||
|
import org.keycloak.models.RealmModel;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
|
||||||
|
class CookieSecureResolver {
|
||||||
|
|
||||||
|
private final KeycloakContext context;
|
||||||
|
private final boolean sameSiteLegacyEnabled;
|
||||||
|
|
||||||
|
CookieSecureResolver(KeycloakContext context, boolean sameSiteLegacyEnabled) {
|
||||||
|
this.context = context;
|
||||||
|
this.sameSiteLegacyEnabled = sameSiteLegacyEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean resolveSecure(NewCookie.SameSite sameSite) {
|
||||||
|
// Due to cookies with SameSite=none secure context is always required when same-site legacy cookies are disabled
|
||||||
|
if (!sameSiteLegacyEnabled) {
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
// SameSite=none requires secure context
|
||||||
|
if (NewCookie.SameSite.NONE.equals(sameSite)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
URI requestUri = context.getUri().getRequestUri();
|
||||||
|
RealmModel realm = context.getRealm();
|
||||||
|
if (realm != null && realm.getSslRequired().isRequired(requestUri.getHost())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -1,31 +1,36 @@
|
||||||
package org.keycloak.cookie;
|
package org.keycloak.cookie;
|
||||||
|
|
||||||
import jakarta.ws.rs.core.Cookie;
|
import jakarta.ws.rs.core.Cookie;
|
||||||
|
import jakarta.ws.rs.core.NewCookie;
|
||||||
import org.jboss.logging.Logger;
|
import org.jboss.logging.Logger;
|
||||||
import org.keycloak.common.util.ServerCookie;
|
|
||||||
import org.keycloak.http.HttpCookie;
|
|
||||||
import org.keycloak.models.KeycloakContext;
|
import org.keycloak.models.KeycloakContext;
|
||||||
import org.keycloak.models.RealmModel;
|
|
||||||
import org.keycloak.services.resources.RealmsResource;
|
|
||||||
import org.keycloak.urls.UrlType;
|
|
||||||
|
|
||||||
import java.net.URI;
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
public class DefaultCookieProvider implements CookieProvider {
|
public class DefaultCookieProvider implements CookieProvider {
|
||||||
|
|
||||||
private static final Logger logger = Logger.getLogger(DefaultCookieProvider.class);
|
private static final Logger logger = Logger.getLogger(DefaultCookieProvider.class);
|
||||||
|
|
||||||
private final KeycloakContext context;
|
private final KeycloakContext context;
|
||||||
|
|
||||||
|
private CookiePathResolver pathResolver;
|
||||||
|
|
||||||
|
private CookieSecureResolver secureResolver;
|
||||||
|
|
||||||
private final Map<String, Cookie> cookies;
|
private final Map<String, Cookie> cookies;
|
||||||
|
|
||||||
private final boolean legacyCookiesEnabled;
|
private final boolean sameSiteLegacyEnabled;
|
||||||
|
|
||||||
public DefaultCookieProvider(KeycloakContext context, boolean legacyCookiesEnabled) {
|
public DefaultCookieProvider(KeycloakContext context, boolean sameSiteLegacyEnabled) {
|
||||||
this.context = context;
|
this.context = context;
|
||||||
this.cookies = context.getRequestHeaders().getCookies();
|
this.cookies = context.getRequestHeaders().getCookies();
|
||||||
this.legacyCookiesEnabled = legacyCookiesEnabled;
|
this.pathResolver = new CookiePathResolver(context);
|
||||||
|
this.secureResolver = new CookieSecureResolver(context, sameSiteLegacyEnabled);
|
||||||
|
this.sameSiteLegacyEnabled = sameSiteLegacyEnabled;
|
||||||
|
|
||||||
|
if (logger.isTraceEnabled()) {
|
||||||
|
String cookieNames = String.join(", ", this.cookies.keySet());
|
||||||
|
logger.tracef("Path: %s, cookies: %s", context.getUri().getRequestUri().getRawPath(), cookieNames);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -40,59 +45,90 @@ public class DefaultCookieProvider implements CookieProvider {
|
||||||
@Override
|
@Override
|
||||||
public void set(CookieType cookieType, String value, int maxAge) {
|
public void set(CookieType cookieType, String value, int maxAge) {
|
||||||
String name = cookieType.getName();
|
String name = cookieType.getName();
|
||||||
ServerCookie.SameSiteAttributeValue sameSite = cookieType.getScope().getSameSite();
|
NewCookie.SameSite sameSite = cookieType.getScope().getSameSite();
|
||||||
boolean secure = resolveSecure(sameSite);
|
boolean secure = secureResolver.resolveSecure(sameSite);
|
||||||
String path = resolvePath(cookieType);
|
String path = pathResolver.resolvePath(cookieType);
|
||||||
boolean httpOnly = cookieType.getScope().isHttpOnly();
|
boolean httpOnly = cookieType.getScope().isHttpOnly();
|
||||||
|
|
||||||
HttpCookie newCookie = new HttpCookie(1, name, value, path, null, null, maxAge, secure, httpOnly, sameSite);
|
NewCookie newCookie = new NewCookie.Builder(name)
|
||||||
|
.version(1)
|
||||||
|
.value(value)
|
||||||
|
.path(path)
|
||||||
|
.maxAge(maxAge)
|
||||||
|
.secure(secure)
|
||||||
|
.httpOnly(httpOnly)
|
||||||
|
.sameSite(sameSite)
|
||||||
|
.build();
|
||||||
|
|
||||||
context.getHttpResponse().setCookieIfAbsent(newCookie);
|
context.getHttpResponse().setCookieIfAbsent(newCookie);
|
||||||
|
|
||||||
logger.tracef("Setting cookie: name: %s, path: %s, same-site: %s, secure: %s, http-only: %s, max-age: %d", name, path, sameSite, secure, httpOnly, maxAge);
|
logger.tracef("Setting cookie: name: %s, path: %s, same-site: %s, secure: %s, http-only: %s, max-age: %d", name, path, sameSite, secure, httpOnly, maxAge);
|
||||||
|
|
||||||
if (legacyCookiesEnabled && cookieType.supportsSameSiteLegacy()) {
|
setSameSiteLegacy(cookieType, value, maxAge);
|
||||||
if (ServerCookie.SameSiteAttributeValue.NONE.equals(sameSite)) {
|
}
|
||||||
secure = resolveSecure(null);
|
|
||||||
String legacyName = cookieType.getSameSiteLegacyName();
|
|
||||||
HttpCookie legacyCookie = new HttpCookie(1, legacyName, value, path, null, null, maxAge, secure, httpOnly, null);
|
|
||||||
context.getHttpResponse().setCookieIfAbsent(legacyCookie);
|
|
||||||
|
|
||||||
logger.tracef("Setting legacy cookie: name: %s, path: %s, same-site: %s, secure: %s, http-only: %s, max-age: %d", legacyName, path, sameSite, secure, httpOnly, maxAge);
|
private void setSameSiteLegacy(CookieType cookieType, String value, int maxAge) {
|
||||||
}
|
if (sameSiteLegacyEnabled && cookieType.supportsSameSiteLegacy()) {
|
||||||
} else {
|
String legacyName = cookieType.getSameSiteLegacyName();
|
||||||
expireLegacy(cookieType);
|
boolean legacySecure = secureResolver.resolveSecure(null);
|
||||||
|
String path = pathResolver.resolvePath(cookieType);
|
||||||
|
boolean httpOnly = cookieType.getScope().isHttpOnly();
|
||||||
|
|
||||||
|
NewCookie legacyCookie = new NewCookie.Builder(legacyName)
|
||||||
|
.version(1)
|
||||||
|
.value(value)
|
||||||
|
.maxAge(maxAge)
|
||||||
|
.path(path)
|
||||||
|
.secure(legacySecure)
|
||||||
|
.httpOnly(httpOnly)
|
||||||
|
.build();
|
||||||
|
context.getHttpResponse().setCookieIfAbsent(legacyCookie);
|
||||||
|
|
||||||
|
logger.tracef("Setting legacy cookie: name: %s, path: %s, same-site: %s, secure: %s, http-only: %s, max-age: %d", legacyName, path, null, legacySecure, httpOnly, maxAge);
|
||||||
|
} else if (cookieType.supportsSameSiteLegacy()) {
|
||||||
|
expireSameSiteLegacy(cookieType);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String get(CookieType cookieType) {
|
public String get(CookieType cookieType) {
|
||||||
Cookie cookie = cookies.get(cookieType.getName());
|
Cookie cookie = cookies.get(cookieType.getName());
|
||||||
if (cookie == null && cookieType.supportsSameSiteLegacy()) {
|
if (cookie == null) {
|
||||||
cookie = cookies.get(cookieType.getSameSiteLegacyName());
|
cookie = getSameSiteLegacyCookie(cookieType);
|
||||||
}
|
}
|
||||||
return cookie != null ? cookie.getValue() : null;
|
return cookie != null ? cookie.getValue() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
private Cookie getSameSiteLegacyCookie(CookieType cookieType) {
|
||||||
public void expire(CookieType cookieType) {
|
|
||||||
Cookie cookie = cookies.get(cookieType.getName());
|
|
||||||
expire(cookie, cookieType);
|
|
||||||
|
|
||||||
expireLegacy(cookieType);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void expireLegacy(CookieType cookieType) {
|
|
||||||
if (cookieType.supportsSameSiteLegacy()) {
|
if (cookieType.supportsSameSiteLegacy()) {
|
||||||
String legacyName = cookieType.getSameSiteLegacyName();
|
return cookies.get(cookieType.getSameSiteLegacyName());
|
||||||
Cookie legacyCookie = cookies.get(legacyName);
|
} else {
|
||||||
expire(legacyCookie, cookieType);
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void expire(Cookie cookie, CookieType cookieType) {
|
@Override
|
||||||
|
public void expire(CookieType cookieType) {
|
||||||
|
expire(cookieType.getName(), cookieType);
|
||||||
|
expireSameSiteLegacy(cookieType);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void expireSameSiteLegacy(CookieType cookieType) {
|
||||||
|
if (cookieType.supportsSameSiteLegacy()) {
|
||||||
|
expire(cookieType.getSameSiteLegacyName(), cookieType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void expire(String cookieName, CookieType cookieType) {
|
||||||
|
Cookie cookie = cookies.get(cookieName);
|
||||||
if (cookie != null) {
|
if (cookie != null) {
|
||||||
String path = resolvePath(cookieType);
|
String path = pathResolver.resolvePath(cookieType);
|
||||||
HttpCookie newCookie = new HttpCookie(1, cookie.getName(), "", path, null, null, CookieMaxAge.EXPIRED, false, false, null);
|
NewCookie newCookie = new NewCookie.Builder(cookieName)
|
||||||
|
.version(1)
|
||||||
|
.path(path)
|
||||||
|
.maxAge(CookieMaxAge.EXPIRED)
|
||||||
|
.build();
|
||||||
|
|
||||||
context.getHttpResponse().setCookieIfAbsent(newCookie);
|
context.getHttpResponse().setCookieIfAbsent(newCookie);
|
||||||
|
|
||||||
logger.tracef("Expiring cookie: name: %s, path: %s", cookie.getName(), path);
|
logger.tracef("Expiring cookie: name: %s, path: %s", cookie.getName(), path);
|
||||||
|
@ -103,31 +139,4 @@ public class DefaultCookieProvider implements CookieProvider {
|
||||||
public void close() {
|
public void close() {
|
||||||
}
|
}
|
||||||
|
|
||||||
private String resolvePath(CookieType cookieType) {
|
|
||||||
switch (cookieType.getPath()) {
|
|
||||||
case REALM:
|
|
||||||
return RealmsResource.realmBaseUrl(context.getUri()).path("/").build(context.getRealm().getName()).getRawPath();
|
|
||||||
case REQUEST:
|
|
||||||
return context.getUri().getRequestUri().getRawPath();
|
|
||||||
default:
|
|
||||||
throw new IllegalArgumentException("Unsupported enum value " + cookieType.getPath().name());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean resolveSecure(ServerCookie.SameSiteAttributeValue sameSite) {
|
|
||||||
URI requestUri = context.getUri().getRequestUri();
|
|
||||||
|
|
||||||
// SameSite=none requires secure context
|
|
||||||
if (ServerCookie.SameSiteAttributeValue.NONE.equals(sameSite)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
RealmModel realm = context.getRealm();
|
|
||||||
if (realm != null && realm.getSslRequired().isRequired(requestUri.getHost())) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,19 +3,24 @@ package org.keycloak.cookie;
|
||||||
import org.keycloak.Config;
|
import org.keycloak.Config;
|
||||||
import org.keycloak.models.KeycloakSession;
|
import org.keycloak.models.KeycloakSession;
|
||||||
import org.keycloak.models.KeycloakSessionFactory;
|
import org.keycloak.models.KeycloakSessionFactory;
|
||||||
|
import org.keycloak.provider.ProviderConfigProperty;
|
||||||
|
import org.keycloak.provider.ProviderConfigurationBuilder;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
public class DefaultCookieProviderFactory implements CookieProviderFactory {
|
public class DefaultCookieProviderFactory implements CookieProviderFactory {
|
||||||
|
|
||||||
private boolean legacyCookies;
|
private static final String SAME_SITE_LEGACY_KEY = "sameSiteLegacy";
|
||||||
|
private boolean sameSiteLegacyEnabled;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public CookieProvider create(KeycloakSession session) {
|
public CookieProvider create(KeycloakSession session) {
|
||||||
return new DefaultCookieProvider(session.getContext(), legacyCookies);
|
return new DefaultCookieProvider(session.getContext(), sameSiteLegacyEnabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void init(Config.Scope config) {
|
public void init(Config.Scope config) {
|
||||||
legacyCookies = config.getBoolean("legacyCookies", true);
|
sameSiteLegacyEnabled = config.getBoolean(SAME_SITE_LEGACY_KEY, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -31,4 +36,15 @@ public class DefaultCookieProviderFactory implements CookieProviderFactory {
|
||||||
return "default";
|
return "default";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<ProviderConfigProperty> getConfigMetadata() {
|
||||||
|
return ProviderConfigurationBuilder.create()
|
||||||
|
.property()
|
||||||
|
.name(SAME_SITE_LEGACY_KEY)
|
||||||
|
.type("boolean")
|
||||||
|
.helpText("Adds legacy cookies without SameSite parameter")
|
||||||
|
.defaultValue(true)
|
||||||
|
.add()
|
||||||
|
.build();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,6 +24,7 @@ import java.util.Set;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
import org.jboss.logging.Logger;
|
import org.jboss.logging.Logger;
|
||||||
|
import org.keycloak.common.util.Encode;
|
||||||
import org.keycloak.cookie.CookieProvider;
|
import org.keycloak.cookie.CookieProvider;
|
||||||
import org.keycloak.cookie.CookieType;
|
import org.keycloak.cookie.CookieType;
|
||||||
import org.keycloak.models.KeycloakSession;
|
import org.keycloak.models.KeycloakSession;
|
||||||
|
@ -69,7 +70,7 @@ public class AuthenticationStateCookie {
|
||||||
cookie.setRemainingTabs(rootAuthSession.getAuthenticationSessions().keySet());
|
cookie.setRemainingTabs(rootAuthSession.getAuthenticationSessions().keySet());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
String encoded = JsonSerialization.writeValueAsString(cookie);
|
String encoded = Encode.urlEncode(JsonSerialization.writeValueAsString(cookie));
|
||||||
session.getProvider(CookieProvider.class).set(CookieType.AUTH_STATE, encoded, cookieMaxAge);
|
session.getProvider(CookieProvider.class).set(CookieType.AUTH_STATE, encoded, cookieMaxAge);
|
||||||
} catch (IOException ioe) {
|
} catch (IOException ioe) {
|
||||||
throw new IllegalStateException("Exception thrown when encoding cookie", ioe);
|
throw new IllegalStateException("Exception thrown when encoding cookie", ioe);
|
||||||
|
|
|
@ -17,8 +17,7 @@
|
||||||
|
|
||||||
package org.keycloak.services;
|
package org.keycloak.services;
|
||||||
|
|
||||||
import jakarta.ws.rs.core.HttpHeaders;
|
import jakarta.ws.rs.core.NewCookie;
|
||||||
import org.keycloak.http.HttpCookie;
|
|
||||||
import org.keycloak.http.HttpResponse;
|
import org.keycloak.http.HttpResponse;
|
||||||
|
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
|
@ -27,7 +26,7 @@ import java.util.Set;
|
||||||
public class HttpResponseImpl implements HttpResponse {
|
public class HttpResponseImpl implements HttpResponse {
|
||||||
|
|
||||||
private final org.jboss.resteasy.spi.HttpResponse delegate;
|
private final org.jboss.resteasy.spi.HttpResponse delegate;
|
||||||
private Set<HttpCookie> cookies;
|
private Set<NewCookie> newCookies;
|
||||||
|
|
||||||
public HttpResponseImpl(org.jboss.resteasy.spi.HttpResponse delegate) {
|
public HttpResponseImpl(org.jboss.resteasy.spi.HttpResponse delegate) {
|
||||||
this.delegate = delegate;
|
this.delegate = delegate;
|
||||||
|
@ -54,17 +53,17 @@ public class HttpResponseImpl implements HttpResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void setCookieIfAbsent(HttpCookie cookie) {
|
public void setCookieIfAbsent(NewCookie newCookie) {
|
||||||
if (cookie == null) {
|
if (newCookie == null) {
|
||||||
throw new IllegalArgumentException("Cookie is null");
|
throw new IllegalArgumentException("Cookie is null");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cookies == null) {
|
if (newCookies == null) {
|
||||||
cookies = new HashSet<>();
|
newCookies = new HashSet<>();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cookies.add(cookie)) {
|
if (newCookies.add(newCookie)) {
|
||||||
addHeader(HttpHeaders.SET_COOKIE, cookie.toHeaderValue());
|
delegate.addNewCookie(newCookie);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -17,9 +17,7 @@
|
||||||
|
|
||||||
package org.keycloak.services.managers;
|
package org.keycloak.services.managers;
|
||||||
|
|
||||||
import jakarta.ws.rs.core.UriInfo;
|
|
||||||
import org.jboss.logging.Logger;
|
import org.jboss.logging.Logger;
|
||||||
import org.keycloak.common.util.ServerCookie.SameSiteAttributeValue;
|
|
||||||
import org.keycloak.common.util.Time;
|
import org.keycloak.common.util.Time;
|
||||||
import org.keycloak.cookie.CookieProvider;
|
import org.keycloak.cookie.CookieProvider;
|
||||||
import org.keycloak.cookie.CookieType;
|
import org.keycloak.cookie.CookieType;
|
||||||
|
|
|
@ -109,14 +109,13 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCookieByName(name) {
|
function getCookieByName(name) {
|
||||||
const cookies = new Map();
|
|
||||||
|
|
||||||
for (const cookie of document.cookie.split(";")) {
|
for (const cookie of document.cookie.split(";")) {
|
||||||
const [key, value] = cookie.split("=").map((value) => value.trim());
|
const [key, value] = cookie.split("=").map((value) => value.trim());
|
||||||
cookies.set(key, value);
|
if (key === name) {
|
||||||
|
return value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1) : value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
return cookies.get(name) ?? null;
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
@ -19,7 +19,6 @@ import org.keycloak.testsuite.client.KeycloakTestingClient;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
public class DefaultCookieProviderTest extends AbstractKeycloakTest {
|
public class DefaultCookieProviderTest extends AbstractKeycloakTest {
|
||||||
|
|
||||||
|
@ -64,6 +63,32 @@ public class DefaultCookieProviderTest extends AbstractKeycloakTest {
|
||||||
assertCookie(response, "WELCOME_STATE_CHECKER", "my-welcome-csrf", "/auth/realms/master/testing/run-on-server", 300, false, true, "Strict", false);
|
assertCookie(response, "WELCOME_STATE_CHECKER", "my-welcome-csrf", "/auth/realms/master/testing/run-on-server", 300, false, true, "Strict", false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testSessionCookieValue() {
|
||||||
|
// Set cookie value with '/' that results in cookies value being quoted
|
||||||
|
final String sessionValue = "my-realm/5256327f-049f-4d01-acb9-68c7936bdeb3/c6cd1f10-40ab-44c0-b77b-6028350d8564";
|
||||||
|
Response response = testing.server("master").runWithResponse(session -> {
|
||||||
|
CookieProvider cookies = session.getProvider(CookieProvider.class);
|
||||||
|
cookies.set(CookieType.SESSION, sessionValue, 444);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cookie values from response.getCookies() removes quotes
|
||||||
|
Assert.assertEquals(sessionValue, response.getCookies().get(CookieType.SESSION.getName()).getValue());
|
||||||
|
|
||||||
|
// Cookie values from the Set-Cookie header includes quotes
|
||||||
|
String setHeader = getSetHeader(response, CookieType.SESSION.getName());
|
||||||
|
Assert.assertTrue(setHeader.startsWith(CookieType.SESSION.getName() + "=\"" + sessionValue + "\";"));
|
||||||
|
|
||||||
|
// Send Cookie header with quoted value for cookie
|
||||||
|
filter.setHeader("Cookie", "KEYCLOAK_SESSION=\"" + sessionValue + "\";");
|
||||||
|
|
||||||
|
// Check cookie value matches original value without quotes
|
||||||
|
testing.server().run(session -> {
|
||||||
|
String cookieValue = session.getProvider(CookieProvider.class).get(CookieType.SESSION);
|
||||||
|
Assert.assertEquals(sessionValue, cookieValue);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void testExpire() {
|
public void testExpire() {
|
||||||
filter.setHeader("Cookie", "AUTH_SESSION_ID=new;KC_RESTART=new;");
|
filter.setHeader("Cookie", "AUTH_SESSION_ID=new;KC_RESTART=new;");
|
||||||
|
@ -106,7 +131,7 @@ public class DefaultCookieProviderTest extends AbstractKeycloakTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void testSameSiteLegacyExpire() {
|
public void testSameSiteLegacyExpire() {
|
||||||
filter.setHeader("Cookie", "AUTH_SESSION_ID=new;AUTH_SESSION_ID_LEGACY=legacy;KC_RESTART_LEGACY=ignore;KEYCLOAK_LOCALE=foobar");
|
filter.setHeader("Cookie", "AUTH_SESSION_ID=new; AUTH_SESSION_ID_LEGACY=legacy; KC_RESTART_LEGACY=ignore; KEYCLOAK_LOCALE=foobar");
|
||||||
|
|
||||||
Response response = testing.server().runWithResponse(session -> {
|
Response response = testing.server().runWithResponse(session -> {
|
||||||
session.getProvider(CookieProvider.class).expire(CookieType.AUTH_SESSION_ID);
|
session.getProvider(CookieProvider.class).expire(CookieType.AUTH_SESSION_ID);
|
||||||
|
@ -115,10 +140,27 @@ public class DefaultCookieProviderTest extends AbstractKeycloakTest {
|
||||||
|
|
||||||
Map<String, NewCookie> cookies = response.getCookies();
|
Map<String, NewCookie> cookies = response.getCookies();
|
||||||
Assert.assertEquals(2, cookies.size());
|
Assert.assertEquals(2, cookies.size());
|
||||||
assertCookie(response, "AUTH_SESSION_ID", "", "/auth/realms/master/", 0, false, false, null, true);
|
assertCookie(response, "AUTH_SESSION_ID", "", "/auth/realms/master/", 0, false, false, null, false);
|
||||||
|
assertCookie(response, "AUTH_SESSION_ID_LEGACY", "", "/auth/realms/master/", 0, false, false, null, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void assertCookie(Response response, String name, String value, String path, int maxAge, boolean secure, boolean httpOnly, String sameSite, boolean hasLegacy) {
|
@Test
|
||||||
|
public void testCustomCookie() {
|
||||||
|
Response response = testing.server().runWithResponse(session -> {
|
||||||
|
NewCookie newCookie = new NewCookie.Builder("mycookie")
|
||||||
|
.maxAge(1232)
|
||||||
|
.value("myvalue")
|
||||||
|
.path(session.getContext().getUri().getRequestUri().getRawPath())
|
||||||
|
.build();
|
||||||
|
session.getContext().getHttpResponse().setCookieIfAbsent(newCookie);
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, NewCookie> cookies = response.getCookies();
|
||||||
|
Assert.assertEquals(1, cookies.size());
|
||||||
|
assertCookie(response, "mycookie", "myvalue", "/auth/realms/master/testing/run-on-server", 1232, false, false, null, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertCookie(Response response, String name, String value, String path, int maxAge, boolean secure, boolean httpOnly, String sameSite, boolean verifyLegacy) {
|
||||||
Map<String, NewCookie> cookies = response.getCookies();
|
Map<String, NewCookie> cookies = response.getCookies();
|
||||||
NewCookie cookie = cookies.get(name);
|
NewCookie cookie = cookies.get(name);
|
||||||
Assert.assertNotNull(cookie);
|
Assert.assertNotNull(cookie);
|
||||||
|
@ -128,18 +170,22 @@ public class DefaultCookieProviderTest extends AbstractKeycloakTest {
|
||||||
Assert.assertEquals(secure || "None".equals(sameSite), cookie.isSecure());
|
Assert.assertEquals(secure || "None".equals(sameSite), cookie.isSecure());
|
||||||
Assert.assertEquals(httpOnly, cookie.isHttpOnly());
|
Assert.assertEquals(httpOnly, cookie.isHttpOnly());
|
||||||
|
|
||||||
String setHeader = (String) response.getHeaders().get("Set-Cookie").stream().filter(v -> ((String) v).startsWith(name)).findFirst().get();
|
String setHeader = getSetHeader(response, name);
|
||||||
if (sameSite == null) {
|
if (sameSite == null) {
|
||||||
Assert.assertFalse(setHeader.contains("SameSite"));
|
Assert.assertFalse(setHeader.contains("SameSite"));
|
||||||
} else {
|
} else {
|
||||||
Assert.assertTrue(setHeader.contains("SameSite=" + sameSite));
|
Assert.assertTrue("Expected SameSite=" + sameSite + ", header was: " + setHeader, setHeader.contains("SameSite=" + sameSite));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasLegacy) {
|
if (verifyLegacy) {
|
||||||
assertCookie(response, name + "_LEGACY", value, path, maxAge, secure, httpOnly, null, false);
|
assertCookie(response, name + "_LEGACY", value, path, maxAge, secure, httpOnly, null, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String getSetHeader(Response response, String name) {
|
||||||
|
return (String) response.getHeaders().get("Set-Cookie").stream().filter(v -> ((String) v).startsWith(name)).findFirst().get();
|
||||||
|
}
|
||||||
|
|
||||||
private KeycloakTestingClient createTestingClient(String serverUrl) {
|
private KeycloakTestingClient createTestingClient(String serverUrl) {
|
||||||
ResteasyClientBuilder restEasyClientBuilder = KeycloakTestingClient.getRestEasyClientBuilder(serverUrl);
|
ResteasyClientBuilder restEasyClientBuilder = KeycloakTestingClient.getRestEasyClientBuilder(serverUrl);
|
||||||
ResteasyClient resteasyClient = restEasyClientBuilder.build();
|
ResteasyClient resteasyClient = restEasyClientBuilder.build();
|
||||||
|
|
|
@ -32,7 +32,7 @@ function checkAuthState(authSessionId, tabId, loginRestartUrl) {
|
||||||
// Attempt to parse the auth state as JSON.
|
// Attempt to parse the auth state as JSON.
|
||||||
let authState;
|
let authState;
|
||||||
try {
|
try {
|
||||||
authState = JSON.parse(authStateRaw);
|
authState = JSON.parse(decodeURIComponent(authStateRaw));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// The auth state is not valid JSON, exit.
|
// The auth state is not valid JSON, exit.
|
||||||
return;
|
return;
|
||||||
|
@ -64,12 +64,11 @@ function getAuthState() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCookieByName(name) {
|
function getCookieByName(name) {
|
||||||
const cookies = new Map();
|
|
||||||
|
|
||||||
for (const cookie of document.cookie.split(";")) {
|
for (const cookie of document.cookie.split(";")) {
|
||||||
const [key, value] = cookie.split("=").map((value) => value.trim());
|
const [key, value] = cookie.split("=").map((value) => value.trim());
|
||||||
cookies.set(key, value);
|
if (key === name) {
|
||||||
|
return value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1) : value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
return cookies.get(name) ?? null;
|
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue