Skip to content

Commit bd4f21d

Browse files
committed
Cookie API now supports the SameSite parameter.
1 parent 042bec0 commit bd4f21d

File tree

4 files changed

+251
-1
lines changed

4 files changed

+251
-1
lines changed

jooby/src/main/java/io/jooby/Cookie.java

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,14 @@ public class Cookie {
6969
*/
7070
private long maxAge = -1;
7171

72+
/**
73+
* Value for the 'SameSite' cookie attribute.
74+
*
75+
* @see <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite">
76+
* https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite</a>
77+
*/
78+
private SameSite sameSite;
79+
7280
/**
7381
* Creates a response cookie.
7482
*
@@ -97,6 +105,7 @@ private Cookie(@Nonnull Cookie cookie) {
97105
this.path = cookie.path;
98106
this.secure = cookie.secure;
99107
this.httpOnly = cookie.httpOnly;
108+
this.sameSite = cookie.sameSite;
100109
}
101110

102111
/**
@@ -238,12 +247,19 @@ public boolean isSecure() {
238247
}
239248

240249
/**
241-
* Set cookie secure flag..
250+
* Set cookie secure flag.
242251
*
243252
* @param secure Cookie's secure.
244253
* @return This cookie.
254+
* @throws IllegalArgumentException if {@code false} is specified and the 'SameSite'
255+
* attribute value requires a secure cookie.
245256
*/
246257
public @Nonnull Cookie setSecure(boolean secure) {
258+
if (sameSite != null && sameSite.requiresSecure() && !secure) {
259+
throw new IllegalArgumentException("Cookies with SameSite=" + sameSite.getValue()
260+
+ " must be flagged as Secure. Call Cookie.setSameSite(...) with an argument"
261+
+ " allowing non-secure cookies before calling Cookie.setSecure(false).");
262+
}
247263
this.secure = secure;
248264
return this;
249265
}
@@ -297,6 +313,60 @@ public long getMaxAge() {
297313
return this;
298314
}
299315

316+
/**
317+
* Returns the value for the 'SameSite' parameter.
318+
* <ul>
319+
* <li>{@link SameSite#LAX} - Cookies are allowed to be sent with top-level navigations and
320+
* will be sent along with GET request initiated by third party website. This is the default
321+
* value in modern browsers.</li>
322+
* <li>{@link SameSite#STRICT} - Cookies will only be sent in a first-party context and not be
323+
* sent along with requests initiated by third party websites.</li>
324+
* <li>{@link SameSite#NONE} - Cookies will be sent in all contexts, i.e sending cross-origin
325+
* is allowed. Requires the {@code Secure} attribute in latest browser versions.</li>
326+
* <li>{@code null} - Not specified.</li>
327+
* </ul>
328+
*
329+
* @return the value for 'SameSite' parameter.
330+
* @see #setSecure(boolean)
331+
* @see <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite">
332+
* https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite</a>
333+
*/
334+
@Nullable
335+
public SameSite getSameSite() {
336+
return sameSite;
337+
}
338+
339+
/**
340+
* Sets the value for the 'SameSite' parameter.
341+
* <ul>
342+
* <li>{@link SameSite#LAX} - Cookies are allowed to be sent with top-level navigations and
343+
* will be sent along with GET request initiated by third party website. This is the default
344+
* value in modern browsers.</li>
345+
* <li>{@link SameSite#STRICT} - Cookies will only be sent in a first-party context and not be
346+
* sent along with requests initiated by third party websites.</li>
347+
* <li>{@link SameSite#NONE} - Cookies will be sent in all contexts, i.e sending cross-origin
348+
* is allowed. Requires the {@code Secure} attribute in latest browser versions.</li>
349+
* <li>{@code null} - Not specified.</li>
350+
* </ul>
351+
*
352+
* @param sameSite the value for the 'SameSite' parameter.
353+
* @return this instance.
354+
* @throws IllegalArgumentException if a value requiring a secure cookie is specified and this
355+
* cookie is not flagged as secure.
356+
* @see #setSecure(boolean)
357+
* @see <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite">
358+
* https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite</a>
359+
*/
360+
public Cookie setSameSite(@Nullable SameSite sameSite) {
361+
if (sameSite != null && sameSite.requiresSecure() && !isSecure()) {
362+
throw new IllegalArgumentException("Cookies with SameSite=" + sameSite.getValue()
363+
+ " must be flagged as Secure. Call Cookie.setSecure(true)"
364+
+ " before calling Cookie.setSameSite(...).");
365+
}
366+
this.sameSite = sameSite;
367+
return this;
368+
}
369+
300370
@Override public String toString() {
301371
StringBuilder buff = new StringBuilder();
302372
buff.append(name).append("=");
@@ -334,6 +404,12 @@ public long getMaxAge() {
334404
append(sb, domain);
335405
}
336406

407+
// SameSite
408+
if (sameSite != null) {
409+
sb.append(";SameSite=");
410+
append(sb, sameSite.getValue());
411+
}
412+
337413
// Secure
338414
if (secure) {
339415
sb.append(";Secure");
@@ -491,6 +567,8 @@ public long getMaxAge() {
491567
value(conf, namespace + ".httpOnly", Config::getBoolean, cookie::setHttpOnly);
492568
value(conf, namespace + ".maxAge", (c, path) -> c.getDuration(path, TimeUnit.SECONDS),
493569
cookie::setMaxAge);
570+
value(conf, namespace + ".sameSite", (c, path) -> SameSite.of(c.getString(path)),
571+
cookie::setSameSite);
494572
return Optional.of(cookie);
495573
}
496574
return Optional.empty();
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package io.jooby;
2+
3+
import static java.util.Arrays.stream;
4+
import static java.util.stream.Collectors.joining;
5+
6+
/**
7+
* The SameSite attribute of the Set-Cookie HTTP response header allows you to declare
8+
* if your cookie should be restricted to a first-party or same-site context.
9+
*
10+
* @see <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite">
11+
* https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite</a>
12+
*/
13+
public enum SameSite {
14+
15+
/**
16+
* Cookies are allowed to be sent with top-level navigations and will be sent along with
17+
* GET request initiated by third party website. This is the default value in modern browsers.
18+
*/
19+
LAX("Lax"),
20+
21+
/**
22+
* Cookies will only be sent in a first-party context and not be sent along with
23+
* requests initiated by third party websites.
24+
*/
25+
STRICT("Strict"),
26+
27+
/**
28+
* Cookies will be sent in all contexts, i.e sending cross-origin is allowed.
29+
* Requires the {@code Secure} attribute in latest browser versions.
30+
*/
31+
NONE("None");
32+
33+
SameSite(String value) {
34+
this.value = value;
35+
}
36+
37+
private final String value;
38+
39+
/**
40+
* Returns the parameter value used in {@code Set-Cookie}.
41+
*
42+
* @return the parameter value.
43+
*/
44+
public String getValue() {
45+
return value;
46+
}
47+
48+
/**
49+
* Returns whether this value requires the cookie to be flagged as {@code Secure}.
50+
*
51+
* @return {@code true} if the cookie should be secure.
52+
*/
53+
public boolean requiresSecure() {
54+
return this == NONE;
55+
}
56+
57+
/**
58+
* Returns an instance of this class based on value it uses in {@code Set-Cookie}.
59+
*
60+
* @param value the value.
61+
* @return an instance of this class.
62+
* @see #getValue()
63+
* @throws IllegalArgumentException if an invalid value is specified.
64+
*/
65+
public static SameSite of(String value) {
66+
return stream(values())
67+
.filter(v -> v.getValue().equals(value))
68+
.findFirst()
69+
.orElseThrow(() -> new IllegalArgumentException("Invalid SameSite value '"
70+
+ value + "'. Use one of: " + stream(values())
71+
.map(SameSite::getValue)
72+
.collect(joining(", "))));
73+
}
74+
}

jooby/src/test/java/io/jooby/CookieTest.java

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
package io.jooby;
22

33
import com.google.common.collect.ImmutableMap;
4+
import com.typesafe.config.ConfigFactory;
45
import org.junit.jupiter.api.Test;
56

67
import static org.junit.jupiter.api.Assertions.assertEquals;
8+
import static org.junit.jupiter.api.Assertions.assertFalse;
9+
import static org.junit.jupiter.api.Assertions.assertThrows;
710

811
public class CookieTest {
912

@@ -38,4 +41,70 @@ public void signUnsign() {
3841
assertEquals("foo=bar&x=u iq", Cookie
3942
.unsign("RcFzlzECN2Lv32Ie9jfSWVr13j6OjllJwDDZe4mVS4c|foo=bar&x=u iq", "987654345!$009P"));
4043
}
44+
45+
@Test
46+
public void testCreateSameSite() {
47+
assertEquals(SameSite.LAX, Cookie.create("mycookie",
48+
ConfigFactory.parseMap(ImmutableMap.of(
49+
"mycookie.name", "foo",
50+
"mycookie.value", "bar",
51+
"mycookie.sameSite", "Lax")))
52+
.map(Cookie::getSameSite)
53+
.orElse(null));
54+
55+
assertEquals(SameSite.NONE, Cookie.create("mycookie",
56+
ConfigFactory.parseMap(ImmutableMap.of(
57+
"mycookie.name", "foo",
58+
"mycookie.value", "bar",
59+
"mycookie.sameSite", "None",
60+
"mycookie.secure", true)))
61+
.map(Cookie::getSameSite)
62+
.orElse(null));
63+
64+
Throwable t1 = assertThrows(IllegalArgumentException.class, () -> Cookie.create("mycookie",
65+
ConfigFactory.parseMap(ImmutableMap.of(
66+
"mycookie.name", "foo",
67+
"mycookie.value", "bar",
68+
"mycookie.sameSite", "None"))));
69+
70+
assertEquals("Cookies with SameSite=None"
71+
+ " must be flagged as Secure. Call Cookie.setSecure(true)"
72+
+ " before calling Cookie.setSameSite(...).", t1.getMessage());
73+
74+
Throwable t2 = assertThrows(IllegalArgumentException.class, () -> Cookie.create("mycookie",
75+
ConfigFactory.parseMap(ImmutableMap.of(
76+
"mycookie.name", "foo",
77+
"mycookie.value", "bar",
78+
"mycookie.sameSite", "Cheese"))));
79+
80+
assertEquals("Invalid SameSite value 'Cheese'. Use one of: Lax, Strict, None", t2.getMessage());
81+
}
82+
83+
@Test
84+
public void testSameSiteVsSecure() {
85+
Cookie cookie = new Cookie("foo", "bar");
86+
87+
Throwable t1 = assertThrows(IllegalArgumentException.class, () -> cookie.setSameSite(SameSite.NONE));
88+
89+
assertEquals("Cookies with SameSite=None"
90+
+ " must be flagged as Secure. Call Cookie.setSecure(true)"
91+
+ " before calling Cookie.setSameSite(...).", t1.getMessage());
92+
93+
cookie.setSecure(true);
94+
cookie.setSameSite(SameSite.NONE);
95+
96+
assertEquals(SameSite.NONE, cookie.getSameSite());
97+
98+
99+
Throwable t2 = assertThrows(IllegalArgumentException.class, () -> cookie.setSecure(false));
100+
101+
assertEquals("Cookies with SameSite=" + cookie.getSameSite().getValue()
102+
+ " must be flagged as Secure. Call Cookie.setSameSite(...) with an argument"
103+
+ " allowing non-secure cookies before calling Cookie.setSecure(false).", t2.getMessage());
104+
105+
cookie.setSameSite(null);
106+
cookie.setSecure(false);
107+
108+
assertFalse(cookie.isSecure());
109+
}
41110
}

tests/src/test/java/io/jooby/FeaturedTest.java

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1772,6 +1772,15 @@ public void cookies(ServerTestRunner runner) {
17721772
.setResponseCookie(cookie)
17731773
.send(StatusCode.OK);
17741774
});
1775+
1776+
app.get("/same-site", ctx -> {
1777+
Cookie cookie = new Cookie("foo", "bar")
1778+
.setSecure(ctx.query("secure").booleanValue(false))
1779+
.setSameSite(ctx.query("sameSite").toOptional().map(SameSite::of).orElse(null));
1780+
return ctx
1781+
.setResponseCookie(cookie)
1782+
.send(StatusCode.OK);
1783+
});
17751784
}).ready(client -> {
17761785
client.get("/cookies", response -> {
17771786
assertEquals("{}", response.body().string());
@@ -1823,6 +1832,26 @@ public void cookies(ServerTestRunner runner) {
18231832
// Give it -/+5
18241833
assertTrue(minutes >= 25 && minutes <= 35);
18251834
});
1835+
client.get("/same-site", response -> {
1836+
// browser session
1837+
assertEquals("[foo=bar;Path=/]", response.headers("Set-Cookie").toString());
1838+
});
1839+
client.get("/same-site?sameSite=Lax", response -> {
1840+
// browser session
1841+
assertEquals("[foo=bar;Path=/;SameSite=Lax]", response.headers("Set-Cookie").toString());
1842+
});
1843+
client.get("/same-site?sameSite=Strict", response -> {
1844+
// browser session
1845+
assertEquals("[foo=bar;Path=/;SameSite=Strict]", response.headers("Set-Cookie").toString());
1846+
});
1847+
client.get("/same-site?sameSite=None&secure=true", response -> {
1848+
// browser session
1849+
assertEquals("[foo=bar;Path=/;SameSite=None;Secure]", response.headers("Set-Cookie").toString());
1850+
});
1851+
client.get("/same-site?sameSite=None", response -> {
1852+
// browser session
1853+
assertEquals(400, response.code());
1854+
});
18261855
});
18271856
}
18281857

0 commit comments

Comments
 (0)