001/* 002 * Copyright 2022-2025 Revetware LLC. 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016 017package com.soklet.core; 018 019import javax.annotation.Nonnull; 020import javax.annotation.Nullable; 021import javax.annotation.concurrent.NotThreadSafe; 022import javax.annotation.concurrent.ThreadSafe; 023import java.net.HttpCookie; 024import java.time.Duration; 025import java.util.ArrayList; 026import java.util.List; 027import java.util.Objects; 028import java.util.Optional; 029import java.util.stream.Collectors; 030 031import static java.lang.String.format; 032import static java.util.Objects.requireNonNull; 033 034/** 035 * HTTP "response" Cookie representation which supports {@code Set-Cookie} header encoding. 036 * 037 * @author <a href="https://www.revetkn.com">Mark Allen</a> 038 */ 039@ThreadSafe 040public class ResponseCookie { 041 @Nonnull 042 private final String name; 043 @Nullable 044 private final String value; 045 @Nullable 046 private final Duration maxAge; 047 @Nullable 048 private final String domain; 049 @Nullable 050 private final String path; 051 @Nonnull 052 private final Boolean secure; 053 @Nonnull 054 private final Boolean httpOnly; 055 @Nullable 056 private final SameSite sameSite; 057 058 /** 059 * Acquires a builder for {@link ResponseCookie} instances. 060 * 061 * @param name the cookie name 062 * @param value the cookie value 063 * @return the builder 064 */ 065 @Nonnull 066 public static Builder with(@Nonnull String name, 067 @Nullable String value) { 068 requireNonNull(name); 069 return new Builder(name, value); 070 } 071 072 /** 073 * Acquires a builder for {@link ResponseCookie} instances without specifying the cookie's value. 074 * 075 * @param name the cookie name 076 * @return the builder 077 */ 078 @Nonnull 079 public static Builder withName(@Nonnull String name) { 080 requireNonNull(name); 081 return new Builder(name); 082 } 083 084 /** 085 * Vends a mutable copier seeded with this instance's data, suitable for building new instances. 086 * 087 * @return a copier for this instance 088 */ 089 @Nonnull 090 public Copier copy() { 091 return new Copier(this); 092 } 093 094 /** 095 * Given a {@code Set-Cookie} header representation, provide a {@link ResponseCookie} that matches it. 096 * <p> 097 * An example of a {@code Set-Cookie} header representation is {@code Set-Cookie: <cookie-name>=<cookie-value>; Domain=<domain-value>; Secure; HttpOnly} 098 * <p> 099 * Note: while the spec does not forbid multiple cookie name/value pairs to be specified in the same {@code Set-Cookie} header, this format is unusual - Soklet does not currently support parsing these kinds of cookies. 100 * <p> 101 * See <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie">https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie</a> for details. 102 * 103 * @param setCookieHeaderRepresentation a {@code Set-Cookie} header representation 104 * @return a {@link ResponseCookie} representation of the {@code Set-Cookie} header, or {@link Optional#empty()} if the header is null, empty, or does not include cookie data 105 * @throws IllegalArgumentException if the {@code Set-Cookie} header representation is malformed 106 */ 107 @Nonnull 108 public static Optional<ResponseCookie> fromSetCookieHeaderRepresentation(@Nullable String setCookieHeaderRepresentation) { 109 setCookieHeaderRepresentation = setCookieHeaderRepresentation == null ? null : setCookieHeaderRepresentation.trim(); 110 111 if (setCookieHeaderRepresentation == null || setCookieHeaderRepresentation.length() == 0) 112 return Optional.empty(); 113 114 List<HttpCookie> cookies = HttpCookie.parse(setCookieHeaderRepresentation); 115 116 if (cookies.size() == 0) 117 return Optional.empty(); 118 119 // Technically OK per the spec to "fold" multiple cookie name/value pairs into the same header but this is 120 // unusual and we don't support it here. Pick the first cookie and use it. 121 HttpCookie httpCookie = cookies.get(0); 122 123 return Optional.of(ResponseCookie.with(httpCookie.getName(), httpCookie.getValue()) 124 .maxAge(Duration.ofSeconds(httpCookie.getMaxAge())) 125 .domain(httpCookie.getDomain()) 126 .httpOnly(httpCookie.isHttpOnly()) 127 .secure(httpCookie.getSecure()) 128 .path(httpCookie.getPath()) 129 .build()); 130 } 131 132 protected ResponseCookie(@Nonnull Builder builder) { 133 requireNonNull(builder); 134 135 this.name = builder.name; 136 this.value = builder.value; 137 this.maxAge = builder.maxAge; 138 this.domain = builder.domain; 139 this.path = builder.path; 140 this.secure = builder.secure == null ? false : builder.secure; 141 this.httpOnly = builder.httpOnly == null ? false : builder.httpOnly; 142 this.sameSite = builder.sameSite; 143 144 Rfc6265Utils.validateCookieName(getName()); 145 Rfc6265Utils.validateCookieValue(getValue().orElse(null)); 146 Rfc6265Utils.validateDomain(getDomain().orElse(null)); 147 Rfc6265Utils.validatePath(getPath().orElse(null)); 148 } 149 150 /** 151 * Generates a {@code Set-Cookie} header representation of this response cookie, for example {@code Set-Cookie: <cookie-name>=<cookie-value>; Domain=<domain-value>; Secure; HttpOnly} 152 * <p> 153 * See <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie">https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie</a> for details. 154 * 155 * @return this response cookie in {@code Set-Cookie} header format 156 */ 157 @Nonnull 158 public String toSetCookieHeaderRepresentation() { 159 List<String> components = new ArrayList<>(8); 160 161 components.add(format("%s=%s", getName(), getValue().orElse(""))); 162 163 if (getPath().isPresent()) 164 components.add(format("Path=%s", getPath().get())); 165 166 if (getDomain().isPresent()) 167 components.add(format("Domain=%s", getDomain().get())); 168 169 long maxAge = getMaxAge().isPresent() ? getMaxAge().get().toSeconds() : -1; 170 171 if (maxAge >= 0) 172 components.add(format("Max-Age=%d", maxAge)); 173 174 if (getSecure()) 175 components.add("Secure"); 176 177 if (getHttpOnly()) 178 components.add("HttpOnly"); 179 180 if (getSameSite().isPresent()) 181 components.add(format("SameSite=%s", getSameSite().get().getHeaderRepresentation())); 182 183 return components.stream().collect(Collectors.joining("; ")); 184 } 185 186 @Override 187 public int hashCode() { 188 return Objects.hash( 189 getName(), 190 getValue(), 191 getMaxAge(), 192 getDomain(), 193 getPath(), 194 getSecure(), 195 getHttpOnly(), 196 getSameSite() 197 ); 198 } 199 200 @Override 201 public boolean equals(@Nullable Object object) { 202 if (this == object) 203 return true; 204 205 if (!(object instanceof ResponseCookie responseCookie)) 206 return false; 207 208 return Objects.equals(getName(), responseCookie.getName()) 209 && Objects.equals(getValue(), responseCookie.getValue()) 210 && Objects.equals(getMaxAge(), responseCookie.getMaxAge()) 211 && Objects.equals(getDomain(), responseCookie.getDomain()) 212 && Objects.equals(getPath(), responseCookie.getPath()) 213 && Objects.equals(getSecure(), responseCookie.getSecure()) 214 && Objects.equals(getHttpOnly(), responseCookie.getHttpOnly()) 215 && Objects.equals(getSameSite(), responseCookie.getSameSite()); 216 } 217 218 @Override 219 @Nonnull 220 public String toString() { 221 return toSetCookieHeaderRepresentation(); 222 } 223 224 /** 225 * Gets the cookie's name. 226 * 227 * @return the name of the cookie 228 */ 229 @Nonnull 230 public String getName() { 231 return this.name; 232 } 233 234 /** 235 * Gets the cookie's value, if present. 236 * 237 * @return the value of the cookie, or {@link Optional#empty()} if there is none 238 */ 239 @Nonnull 240 public Optional<String> getValue() { 241 return Optional.ofNullable(this.value); 242 } 243 244 /** 245 * Gets the cookie's {@code Max-Age} value expressed as a {@link Duration}, if present. 246 * 247 * @return the {@code Max-Age} value of the cookie, or {@link Optional#empty()} if there is none 248 */ 249 @Nonnull 250 public Optional<Duration> getMaxAge() { 251 return Optional.ofNullable(this.maxAge); 252 } 253 254 /** 255 * Gets the cookie's {@code Domain} value, if present. 256 * 257 * @return the {@code Domain} value of the cookie, or {@link Optional#empty()} if there is none 258 */ 259 @Nonnull 260 public Optional<String> getDomain() { 261 return Optional.ofNullable(this.domain); 262 } 263 264 /** 265 * Gets the cookie's {@code Path} value, if present. 266 * 267 * @return the {@code Path} value of the cookie, or {@link Optional#empty()} if there is none 268 */ 269 @Nonnull 270 public Optional<String> getPath() { 271 return Optional.ofNullable(this.path); 272 } 273 274 /** 275 * Gets the cookie's {@code Secure} flag, if present. 276 * 277 * @return {@code true} if the {@code Secure} flag of the cookie is present, {@code false} otherwise 278 */ 279 @Nonnull 280 public Boolean getSecure() { 281 return this.secure; 282 } 283 284 /** 285 * Gets the cookie's {@code HttpOnly} flag, if present. 286 * 287 * @return {@code true} if the {@code HttpOnly} flag of the cookie is present, {@code false} otherwise 288 */ 289 @Nonnull 290 public Boolean getHttpOnly() { 291 return this.httpOnly; 292 } 293 294 /** 295 * Gets the cookie's {@code SameSite} value, if present. 296 * 297 * @return the {@code SameSite} value of the cookie, or {@link Optional#empty()} if there is none 298 */ 299 @Nonnull 300 public Optional<SameSite> getSameSite() { 301 return Optional.ofNullable(this.sameSite); 302 } 303 304 /** 305 * Values which control whether or not a response cookie is sent with cross-site requests, providing some protection against cross-site request forgery attacks (CSRF). 306 * <p> 307 * See <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value">https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value</a> for details. 308 * 309 * @author <a href="https://www.revetkn.com">Mark Allen</a> 310 */ 311 public enum SameSite { 312 /** 313 * Means that the browser sends the cookie only for same-site requests, that is, requests originating from the same site that set the cookie. 314 * If a request originates from a different domain or scheme (even with the same domain), no cookies with the {@code SameSite=Strict} attribute are sent. 315 */ 316 STRICT("Strict"), 317 /** 318 * Means that the cookie is not sent on cross-site requests, such as on requests to load images or frames, but is sent when a user is navigating to the origin site from an external site (for example, when following a link). 319 * This is the default behavior if the {@code SameSite} attribute is not specified. 320 */ 321 LAX("Lax"), 322 /** 323 * Means that the browser sends the cookie with both cross-site and same-site requests. 324 * The {@code Secure} attribute must also be set when setting this value, like so {@code SameSite=None; Secure}. If {@code Secure} is missing, an error will be logged. 325 */ 326 NONE("None"); 327 328 @Nonnull 329 private final String headerRepresentation; 330 331 SameSite(@Nonnull String headerRepresentation) { 332 requireNonNull(headerRepresentation); 333 this.headerRepresentation = headerRepresentation; 334 } 335 336 /** 337 * Returns the {@link SameSite} enum value that matches the corresponding {@code SameSite} response header value representation (one of {@code Strict}, {@code Lax}, or {@code None} - case-insensitive). 338 * 339 * @param headerRepresentation a case-insensitive HTTP header value - one of {@code Strict}, {@code Lax}, or {@code None} 340 * @return the enum value that corresponds to the given the header representation, or {@link Optional#empty()} if none matches 341 */ 342 @Nonnull 343 public static Optional<SameSite> fromHeaderRepresentation(@Nonnull String headerRepresentation) { 344 requireNonNull(headerRepresentation); 345 346 headerRepresentation = headerRepresentation.trim(); 347 348 for (SameSite sameSite : values()) 349 if (headerRepresentation.equalsIgnoreCase(sameSite.getHeaderRepresentation())) 350 return Optional.of(sameSite); 351 352 return Optional.empty(); 353 } 354 355 /** 356 * The HTTP header value that corresponds to this enum value - one of {@code Strict}, {@code Lax}, or {@code None}. 357 * 358 * @return the HTTP header value for this enum 359 */ 360 @Nonnull 361 public String getHeaderRepresentation() { 362 return this.headerRepresentation; 363 } 364 } 365 366 /** 367 * Builder used to construct instances of {@link ResponseCookie} via {@link ResponseCookie#withName(String)} 368 * or {@link ResponseCookie#with(String, String)}. 369 * <p> 370 * This class is intended for use by a single thread. 371 * 372 * @author <a href="https://www.revetkn.com">Mark Allen</a> 373 */ 374 @NotThreadSafe 375 public static class Builder { 376 @Nonnull 377 private String name; 378 @Nullable 379 private String value; 380 @Nullable 381 private Duration maxAge; 382 @Nullable 383 private String domain; 384 @Nullable 385 private String path; 386 @Nullable 387 private Boolean secure; 388 @Nullable 389 private Boolean httpOnly; 390 @Nullable 391 private SameSite sameSite; 392 393 protected Builder(@Nonnull String name) { 394 requireNonNull(name); 395 this.name = name; 396 } 397 398 protected Builder(@Nonnull String name, 399 @Nullable String value) { 400 requireNonNull(name); 401 this.name = name; 402 this.value = value; 403 } 404 405 @Nonnull 406 public Builder name(@Nonnull String name) { 407 requireNonNull(name); 408 this.name = name; 409 return this; 410 } 411 412 @Nonnull 413 public Builder value(@Nullable String value) { 414 this.value = value; 415 return this; 416 } 417 418 @Nonnull 419 public Builder maxAge(@Nullable Duration maxAge) { 420 this.maxAge = maxAge; 421 return this; 422 } 423 424 @Nonnull 425 public Builder domain(@Nullable String domain) { 426 this.domain = domain; 427 return this; 428 } 429 430 @Nonnull 431 public Builder path(@Nullable String path) { 432 this.path = path; 433 return this; 434 } 435 436 @Nonnull 437 public Builder secure(@Nullable Boolean secure) { 438 this.secure = secure; 439 return this; 440 } 441 442 @Nonnull 443 public Builder httpOnly(@Nullable Boolean httpOnly) { 444 this.httpOnly = httpOnly; 445 return this; 446 } 447 448 @Nonnull 449 public Builder sameSite(@Nullable SameSite sameSite) { 450 this.sameSite = sameSite; 451 return this; 452 } 453 454 @Nonnull 455 public ResponseCookie build() { 456 return new ResponseCookie(this); 457 } 458 } 459 460 /** 461 * Builder used to copy instances of {@link ResponseCookie} via {@link ResponseCookie#copy()}. 462 * <p> 463 * This class is intended for use by a single thread. 464 * 465 * @author <a href="https://www.revetkn.com">Mark Allen</a> 466 */ 467 @NotThreadSafe 468 public static class Copier { 469 @Nonnull 470 private final Builder builder; 471 472 Copier(@Nonnull ResponseCookie responseCookie) { 473 requireNonNull(responseCookie); 474 475 this.builder = new Builder(responseCookie.getName()) 476 .value(responseCookie.getValue().orElse(null)) 477 .maxAge(responseCookie.getMaxAge().orElse(null)) 478 .domain(responseCookie.getDomain().orElse(null)) 479 .path(responseCookie.getPath().orElse(null)) 480 .secure(responseCookie.getSecure()) 481 .httpOnly(responseCookie.getHttpOnly()) 482 .sameSite(responseCookie.getSameSite().orElse(null)); 483 } 484 485 @Nonnull 486 public Copier name(@Nonnull String name) { 487 requireNonNull(name); 488 this.builder.name(name); 489 return this; 490 } 491 492 @Nonnull 493 public Copier value(@Nullable String value) { 494 this.builder.value(value); 495 return this; 496 } 497 498 @Nonnull 499 public Copier maxAge(@Nullable Duration maxAge) { 500 this.builder.maxAge(maxAge); 501 return this; 502 } 503 504 @Nonnull 505 public Copier domain(@Nullable String domain) { 506 this.builder.domain(domain); 507 return this; 508 } 509 510 @Nonnull 511 public Copier path(@Nullable String path) { 512 this.builder.path(path); 513 return this; 514 } 515 516 @Nonnull 517 public Copier secure(@Nullable Boolean secure) { 518 this.builder.secure(secure); 519 return this; 520 } 521 522 @Nonnull 523 public Copier httpOnly(@Nullable Boolean httpOnly) { 524 this.builder.httpOnly(httpOnly); 525 return this; 526 } 527 528 @Nonnull 529 public Copier sameSite(@Nullable SameSite sameSite) { 530 this.builder.sameSite(sameSite); 531 return this; 532 } 533 534 @Nonnull 535 public ResponseCookie finish() { 536 return this.builder.build(); 537 } 538 } 539 540 /** 541 * See https://github.com/spring-projects/spring-framework/blob/main/spring-web/src/main/java/org/springframework/http/ResponseCookie.java 542 * <p> 543 * Copyright 2002-2023 the original author or authors. 544 * <p> 545 * Licensed under the Apache License, Version 2.0 (the "License"); 546 * you may not use this file except in compliance with the License. 547 * You may obtain a copy of the License at 548 * <p> 549 * https://www.apache.org/licenses/LICENSE-2.0 550 * <p> 551 * Unless required by applicable law or agreed to in writing, software 552 * distributed under the License is distributed on an "AS IS" BASIS, 553 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 554 * See the License for the specific language governing permissions and 555 * limitations under the License. 556 * <p> 557 * 558 * @author Rossen Stoyanchev 559 * @author Brian Clozel 560 */ 561 @ThreadSafe 562 private static class Rfc6265Utils { 563 @Nonnull 564 private static final String SEPARATOR_CHARS = new String(new char[]{ 565 '(', ')', '<', '>', '@', ',', ';', ':', '\\', '"', '/', '[', ']', '?', '=', '{', '}', ' ' 566 }); 567 568 @Nonnull 569 private static final String DOMAIN_CHARS = 570 "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ.-"; 571 572 public static void validateCookieName(@Nonnull String name) { 573 requireNonNull(name); 574 575 for (int i = 0; i < name.length(); i++) { 576 char c = name.charAt(i); 577 // CTL = <US-ASCII control chars (octets 0 - 31) and DEL (127)> 578 if (c <= 0x1F || c == 0x7F) { 579 throw new IllegalArgumentException( 580 name + ": RFC2616 token cannot have control chars"); 581 } 582 if (SEPARATOR_CHARS.indexOf(c) >= 0) { 583 throw new IllegalArgumentException( 584 name + ": RFC2616 token cannot have separator chars such as '" + c + "'"); 585 } 586 if (c >= 0x80) { 587 throw new IllegalArgumentException( 588 name + ": RFC2616 token can only have US-ASCII: 0x" + Integer.toHexString(c)); 589 } 590 } 591 } 592 593 public static void validateCookieValue(@Nullable String value) { 594 if (value == null) { 595 return; 596 } 597 int start = 0; 598 int end = value.length(); 599 if (end > 1 && value.charAt(0) == '"' && value.charAt(end - 1) == '"') { 600 start = 1; 601 end--; 602 } 603 for (int i = start; i < end; i++) { 604 char c = value.charAt(i); 605 if (c < 0x21 || c == 0x22 || c == 0x2c || c == 0x3b || c == 0x5c || c == 0x7f) { 606 throw new IllegalArgumentException( 607 "RFC2616 cookie value cannot have '" + c + "'"); 608 } 609 if (c >= 0x80) { 610 throw new IllegalArgumentException( 611 "RFC2616 cookie value can only have US-ASCII chars: 0x" + Integer.toHexString(c)); 612 } 613 } 614 } 615 616 public static void validateDomain(@Nullable String domain) { 617 if (domain == null || domain.trim().length() == 0) { 618 return; 619 } 620 int char1 = domain.charAt(0); 621 int charN = domain.charAt(domain.length() - 1); 622 if (char1 == '-' || charN == '.' || charN == '-') { 623 throw new IllegalArgumentException("Invalid first/last char in cookie domain: " + domain); 624 } 625 for (int i = 0, c = -1; i < domain.length(); i++) { 626 int p = c; 627 c = domain.charAt(i); 628 if (DOMAIN_CHARS.indexOf(c) == -1 || (p == '.' && (c == '.' || c == '-')) || (p == '-' && c == '.')) { 629 throw new IllegalArgumentException(domain + ": invalid cookie domain char '" + (char) c + "'"); 630 } 631 } 632 } 633 634 public static void validatePath(@Nullable String path) { 635 if (path == null) { 636 return; 637 } 638 for (int i = 0; i < path.length(); i++) { 639 char c = path.charAt(i); 640 if (c < 0x20 || c > 0x7E || c == ';') { 641 throw new IllegalArgumentException(path + ": Invalid cookie path char '" + c + "'"); 642 } 643 } 644 } 645 } 646}