diff --git a/http/http/src/main/java/io/helidon/http/ServerResponseHeaders.java b/http/http/src/main/java/io/helidon/http/ServerResponseHeaders.java index c662f4f9ed8..b209525f585 100644 --- a/http/http/src/main/java/io/helidon/http/ServerResponseHeaders.java +++ b/http/http/src/main/java/io/helidon/http/ServerResponseHeaders.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, 2023 Oracle and/or its affiliates. + * Copyright (c) 2022, 2025 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -106,12 +106,24 @@ default ServerResponseHeaders addCookie(String name, String value) { /** * Clears a cookie by adding a {@code Set-Cookie} header with an expiration date in the past. + * It is recommended to use {@link #clearCookie(SetCookie)} instead for better handling + * of Path and Domain. * * @param name name of the cookie. * @return this instance */ ServerResponseHeaders clearCookie(String name); + /** + * Clears a cookie by adding a {@code Set-Cookie} header with an expiration date in the past. + * + * @param setCookie the cookie. + * @return this instance + */ + default ServerResponseHeaders clearCookie(SetCookie setCookie) { + return clearCookie(setCookie.name()); + } + /** * Sets the value of {@link HeaderNames#LAST_MODIFIED} header. *

diff --git a/http/http/src/main/java/io/helidon/http/ServerResponseHeadersImpl.java b/http/http/src/main/java/io/helidon/http/ServerResponseHeadersImpl.java index 5906d5f4aec..2b6ab8ba982 100644 --- a/http/http/src/main/java/io/helidon/http/ServerResponseHeadersImpl.java +++ b/http/http/src/main/java/io/helidon/http/ServerResponseHeadersImpl.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, 2023 Oracle and/or its affiliates. + * Copyright (c) 2022, 2025 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,6 +19,7 @@ import java.time.ZoneId; import java.time.ZonedDateTime; import java.util.List; +import java.util.function.Predicate; import io.helidon.common.LazyValue; @@ -39,13 +40,25 @@ public ServerResponseHeaders addCookie(SetCookie cookie) { return this; } + @Override + public ServerResponseHeaders clearCookie(SetCookie cookie) { + clearCookie(cookie, cookie::equals); + return this; + } + @Override public ServerResponseHeaders clearCookie(String name) { - SetCookie expiredCookie = SetCookie.builder(name, "deleted") - .path("/") + clearCookie(SetCookie.builder(name, "").build(), c -> c.name().equals(name)); + return this; + } + + private void clearCookie(SetCookie cookie, Predicate predicate) { + // expiredCookie same as cookie but with different expiration + SetCookie expiredCookie = SetCookie.builder(cookie) .expires(START_OF_YEAR_1970.get()) .build(); + // update or add new header? if (contains(HeaderNames.SET_COOKIE)) { remove(HeaderNames.SET_COOKIE, it -> { List currentValues = it.allValues(); @@ -53,8 +66,9 @@ public ServerResponseHeaders clearCookie(String name) { boolean found = false; for (int i = 0; i < currentValues.size(); i++) { String currentValue = currentValues.get(i); - if (SetCookie.parse(currentValue).name().equals(name)) { - newValues[i] = expiredCookie.text(); + SetCookie currentCookie = SetCookie.parse(currentValue); + if (predicate.test(currentCookie)) { + newValues[i] = expiredCookie.text(); // replace with expired found = true; } else { newValues[i] = currentValue; @@ -63,15 +77,13 @@ public ServerResponseHeaders clearCookie(String name) { if (!found) { String[] values = new String[newValues.length + 1]; System.arraycopy(newValues, 0, values, 0, newValues.length); - values[values.length - 1] = expiredCookie.text(); + values[values.length - 1] = expiredCookie.text(); // replace with expired newValues = values; } - set(HeaderValues.create(HeaderNames.SET_COOKIE, newValues)); }); } else { addCookie(expiredCookie); } - return this; } } diff --git a/http/http/src/main/java/io/helidon/http/SetCookie.java b/http/http/src/main/java/io/helidon/http/SetCookie.java index 536620a4a28..3ae26b82300 100644 --- a/http/http/src/main/java/io/helidon/http/SetCookie.java +++ b/http/http/src/main/java/io/helidon/http/SetCookie.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2018, 2024 Oracle and/or its affiliates. + * Copyright (c) 2018, 2025 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -67,6 +67,16 @@ public static Builder builder(String name, String value) { return new Builder(name, value); } + /** + * Creates a new fluent API builder using another cookie. + * + * @param setCookie the other cookie + * @return a new fluent API builder + */ + public static Builder builder(SetCookie setCookie) { + return new Builder(setCookie); + } + /** * Parses new instance of {@link SetCookie} from the String representation. * @@ -78,7 +88,7 @@ public static SetCookie parse(String setCookie) { String nameAndValue = cookieParts[0]; int equalsIndex = nameAndValue.indexOf('='); String name = nameAndValue.substring(0, equalsIndex); - String value = nameAndValue.length() == equalsIndex ? null : nameAndValue.substring(equalsIndex + 1); + String value = nameAndValue.substring(equalsIndex + 1); Builder builder = builder(name, value); for (int i = 1; i < cookieParts.length; i++) { @@ -88,7 +98,7 @@ public static SetCookie parse(String setCookie) { String partValue; if (equalsIndex > -1) { partName = cookiePart.substring(0, equalsIndex); - partValue = cookiePart.length() == equalsIndex ? null : cookiePart.substring(equalsIndex + 1); + partValue = cookiePart.substring(equalsIndex + 1); } else { partName = cookiePart; partValue = null; @@ -277,6 +287,24 @@ public String toString() { return result.toString(); } + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof SetCookie setCookie)) { + return false; + } + return Objects.equals(name, setCookie.name) + && Objects.equals(domain, setCookie.domain) + && Objects.equals(path, setCookie.path); + } + + @Override + public int hashCode() { + return Objects.hash(name, domain, path); + } + private static void hasNoValue(String partName, String partValue) { if (partValue != null) { throw new IllegalArgumentException("Set-Cookie parameter " + partName + " has to have no value!"); @@ -349,6 +377,19 @@ private Builder(String name, String value) { this.value = value; } + private Builder(SetCookie other) { + Objects.requireNonNull(other); + this.name = other.name; + this.value = other.value; + this.expires = other.expires; + this.maxAge = other.maxAge; + this.domain = other.domain; + this.path = other.path; + this.secure = other.secure; + this.httpOnly = other.httpOnly; + this.sameSite = other.sameSite; + } + @Override public SetCookie build() { return new SetCookie(this); diff --git a/http/http/src/test/java/io/helidon/http/SetCookieTest.java b/http/http/src/test/java/io/helidon/http/SetCookieTest.java index d9e8c56488d..efeaa6c1d77 100644 --- a/http/http/src/test/java/io/helidon/http/SetCookieTest.java +++ b/http/http/src/test/java/io/helidon/http/SetCookieTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020, 2024 Oracle and/or its affiliates. + * Copyright (c) 2020, 2025 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,6 +16,7 @@ package io.helidon.http; import java.time.Duration; +import java.time.Instant; import org.junit.jupiter.api.Test; @@ -29,17 +30,18 @@ */ public class SetCookieTest { + private static final String TEMPLATE = "some-cookie=some-cookie-value; " + + "Expires=Thu, 22 Oct 2015 07:28:00 GMT; " + + "Max-Age=2592000; " + + "Domain=domain.value; " + + "Path=/; " + + "Secure; " + + "HttpOnly; " + + "SameSite=Lax"; + @Test public void testSetCookiesFromString() { - String template = "some-cookie=some-cookie-value; " - + "Expires=Thu, 22 Oct 2015 07:28:00 GMT; " - + "Max-Age=2592000; " - + "Domain=domain.value; " - + "Path=/; " - + "Secure; " - + "HttpOnly; " - + "SameSite=Lax"; - SetCookie setCookie = SetCookie.parse(template); + SetCookie setCookie = SetCookie.parse(TEMPLATE); assertThat(setCookie.name(), is("some-cookie")); assertThat(setCookie.value(), is("some-cookie-value")); @@ -51,7 +53,7 @@ public void testSetCookiesFromString() { assertThat(setCookie.httpOnly(), is(true)); assertThat(setCookie.sameSite(), optionalValue(is(SetCookie.SameSite.LAX))); - assertThat("Generate same cookie value", setCookie.toString(), is(template)); + assertThat("Generate same cookie value", setCookie.toString(), is(TEMPLATE)); } @Test @@ -63,4 +65,45 @@ public void testSetCookiesInvalidValue() { assertThat(ex.getMessage(), is("Unexpected Set-Cookie part: Invalid")); } + @Test + public void testEmptyValue() { + SetCookie setCookie = SetCookie.parse("some-cookie="); + assertThat(setCookie.value(), is("")); + } + + @Test + public void testEquals() { + SetCookie setCookie1 = SetCookie.parse(TEMPLATE); + SetCookie setCookie2 = SetCookie.builder("some-cookie", "").build(); + SetCookie setCookie3 = SetCookie.builder("some-cookie", "") + .path("/") + .domain("domain.value") + .expires(Instant.now()) // ignored in equals + .build(); + + assertThat("They match name, path and domain", + setCookie1.equals(setCookie3), is(true)); + assertThat("They match name, path and domain", + setCookie1.hashCode(), is(setCookie3.hashCode())); + assertThat("They do not match path or domain", + setCookie1.equals(setCookie2), is(false)); + } + + @Test + public void testCookieBuilder() { + SetCookie setCookie1 = SetCookie.parse(TEMPLATE); + SetCookie setCookie2 = SetCookie.builder(setCookie1).build(); // from setCookie1 + + assertThat(setCookie1.equals(setCookie2), is(true)); + assertThat(setCookie1.hashCode(), is(setCookie2.hashCode())); + assertThat(setCookie2.name(), is("some-cookie")); + assertThat(setCookie2.value(), is("some-cookie-value")); + assertThat(setCookie2.expires(), optionalValue(is(DateTime.parse("Thu, 22 Oct 2015 07:28:00 GMT")))); + assertThat(setCookie2.maxAge(), optionalValue(is(Duration.ofSeconds(2592000)))); + assertThat(setCookie2.domain(), optionalValue(is("domain.value"))); + assertThat(setCookie2.path(), optionalValue(is("/"))); + assertThat(setCookie2.secure(), is(true)); + assertThat(setCookie2.httpOnly(), is(true)); + assertThat(setCookie2.sameSite(), optionalValue(is(SetCookie.SameSite.LAX))); + } } diff --git a/http/tests/cookie/pom.xml b/http/tests/cookie/pom.xml new file mode 100644 index 00000000000..f0ef98d23a6 --- /dev/null +++ b/http/tests/cookie/pom.xml @@ -0,0 +1,52 @@ + + + + 4.0.0 + + io.helidon.http.tests + helidon-http-tests-project + 4.2.0-SNAPSHOT + + + helidon-http-tests-cookie + Helidon HTTP Tests Cookie + + + + io.helidon.webserver.testing.junit5 + helidon-webserver-testing-junit5 + test + + + org.junit.jupiter + junit-jupiter-api + test + + + org.hamcrest + hamcrest-all + test + + + io.helidon.logging + helidon-logging-jul + test + + + diff --git a/http/tests/cookie/src/test/java/io/helidon/http/tests/cookie/CookieTest.java b/http/tests/cookie/src/test/java/io/helidon/http/tests/cookie/CookieTest.java new file mode 100644 index 00000000000..6f8818c3570 --- /dev/null +++ b/http/tests/cookie/src/test/java/io/helidon/http/tests/cookie/CookieTest.java @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2025 Oracle and/or its affiliates. + * + * 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 io.helidon.http.tests.cookie; + +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Optional; + +import io.helidon.http.DateTime; +import io.helidon.http.HeaderNames; +import io.helidon.http.SetCookie; +import io.helidon.webclient.http1.Http1Client; +import io.helidon.webclient.http1.Http1ClientResponse; +import io.helidon.webserver.http.HttpRouting; +import io.helidon.webserver.testing.junit5.ServerTest; +import io.helidon.webserver.testing.junit5.SetUpRoute; + +import org.junit.jupiter.api.Test; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.isIn; + +/** + * Validates that a cookie can be properly cleared in a handler method. An HTTP + * cookie is identified by its name, domain and path. In this test, two cookies + * with the same name but different domains are used. + */ +@ServerTest +class CookieTest { + private static final String START_OF_YEAR_1970 = + ZonedDateTime.of(1970, 1, 1, 0, 0, 0, 0, ZoneId.of("GMT+0")) + .format(DateTime.RFC_1123_DATE_TIME); + + private final Http1Client client; + + CookieTest(Http1Client client) { + this.client = client; + } + + @SetUpRoute + static void route(HttpRouting.Builder router) { + router.addFilter((chain, req, res) -> { + // adds two cookies same name different domains + res.headers().addCookie(newCookie("my-cookie", "my-domain1", "/")); + res.headers().addCookie(newCookie("my-cookie", "my-domain2", "/")); + chain.proceed(); + }) + .get("/cookie", (req, res) -> { + // clears cookie in my-domain2 + res.headers().clearCookie(newCookie("my-cookie", "my-domain2", "/")); + res.status(200).send(); + }); + } + + @Test + void clearCookieTest() { + try (Http1ClientResponse res = client.get("/cookie").request()) { + assertThat(res.headers().contains(HeaderNames.SET_COOKIE), is(true)); + List values = res.headers().get(HeaderNames.SET_COOKIE).allValues(); + assertThat(values.size(), is(2)); + SetCookie first = SetCookie.parse(values.getFirst()); + validateCookie(first); + SetCookie last = SetCookie.parse(values.getLast()); + validateCookie(last); + } + } + + static void validateCookie(SetCookie cookie) { + assertThat(cookie.name(), is("my-cookie")); + assertThat(cookie.path(), is(Optional.of("/"))); + assertThat(cookie.domain().isPresent(), is(true)); + assertThat(cookie.domain().get(), isIn(new String[] {"my-domain1", "my-domain2"})); + if (cookie.domain().get().equals("my-domain1")) { + assertThat(cookie.expires().isEmpty(), is(true)); + } else if (cookie.domain().get().equals("my-domain2")) { + assertThat(cookie.expires().isPresent(), is(true)); + assertThat(cookie.expires().get().format(DateTime.RFC_1123_DATE_TIME), is(START_OF_YEAR_1970)); // cleared + } + } + + static SetCookie newCookie(String name, String domain, String path) { + return SetCookie.builder(name, name + "-value") + .domain(domain) + .path(path) + .build(); + } +} diff --git a/http/tests/pom.xml b/http/tests/pom.xml index 8945ae6a82b..3faecbaf0f6 100644 --- a/http/tests/pom.xml +++ b/http/tests/pom.xml @@ -36,6 +36,7 @@ encoding media + cookie