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}