From b127931a6dc1cb12a94c907a36506b8406ef11ab Mon Sep 17 00:00:00 2001 From: blakeoxx <14984839+blakeoxx@users.noreply.github.com> Date: Mon, 19 Feb 2024 16:52:08 -0600 Subject: [PATCH] feat(ssr): adds server cookie output via response Resolves #266 Cherry-picked from commit 123b698b --- projects/ngx-cookie-service-ssr/package.json | 3 + .../src/lib/ssr-cookie.service.ts | 283 +++++++++++++----- 2 files changed, 207 insertions(+), 79 deletions(-) diff --git a/projects/ngx-cookie-service-ssr/package.json b/projects/ngx-cookie-service-ssr/package.json index 9d86898..ab34641 100644 --- a/projects/ngx-cookie-service-ssr/package.json +++ b/projects/ngx-cookie-service-ssr/package.json @@ -70,6 +70,9 @@ }, { "name": "IceBreakerG" + }, + { + "name": "Blake Ballard (blakeoxx)" } ], "repository": { diff --git a/projects/ngx-cookie-service-ssr/src/lib/ssr-cookie.service.ts b/projects/ngx-cookie-service-ssr/src/lib/ssr-cookie.service.ts index 364424f..8f712c2 100644 --- a/projects/ngx-cookie-service-ssr/src/lib/ssr-cookie.service.ts +++ b/projects/ngx-cookie-service-ssr/src/lib/ssr-cookie.service.ts @@ -1,4 +1,4 @@ -import { Request, Response } from 'express'; +import { CookieOptions, Request, Response } from 'express'; import { Inject, Injectable, InjectionToken, Optional, PLATFORM_ID } from '@angular/core'; import { DOCUMENT, isPlatformBrowser } from '@angular/common'; @@ -56,6 +56,199 @@ export class SsrCookieService { } } + /** + * Converts the provided cookie string to a key-value representation. + * + * @param cookieString - A concatenated string of cookies + * @returns Map - Key-value pairs of the provided cookies + * + * @author: Blake Ballard (blakeoxx) + * @since: 16.2.0 + */ + static cookieStringToMap(cookieString: string): Map { + const cookies = new Map; + + if (cookieString?.length < 1) { + return cookies; + } + + cookieString.split(';').forEach((currentCookie) => { + let [cookieName, cookieValue] = currentCookie.split('='); + + // Remove any extra spaces from the beginning of cookie names. These are a side effect of browser/express cookie concatenation + cookieName = cookieName.replace(/^ +/, ''); + + cookies.set(SsrCookieService.safeDecodeURIComponent(cookieName), SsrCookieService.safeDecodeURIComponent(cookieValue)); + }); + + return cookies; + } + + /** + * Gets the current state of all cookies based on the request and response. Cookies added or changed in the response + * override any old values provided in the response. + * + * Client-side will always just return the document's cookies. + * + * @private + * @returns Map - All cookies from the request and response (or document) in key-value form. + * + * @author: Blake Ballard (blakeoxx) + * @since: 16.2.0 + */ + private getCombinedCookies(): Map { + if (this.documentIsAccessible) { + return SsrCookieService.cookieStringToMap(this.document.cookie); + } + + const requestCookies = SsrCookieService.cookieStringToMap(this.request?.headers.cookie || ''); + + let responseCookies: string | string[] = (this.response?.get('Set-Cookie') || []); + if (!Array.isArray(responseCookies)) { + responseCookies = [responseCookies]; + } + + let allCookies = new Map(requestCookies); + // Parse and merge response cookies with request cookies + responseCookies.forEach((currentCookie) => { + // Response cookie headers represent individual cookies and their options, so we parse them similar to other cookie strings, but slightly different + let [cookieName, cookieValue] = currentCookie.split(';')[0].split('='); + if (cookieName !== '') { + allCookies.set(SsrCookieService.safeDecodeURIComponent(cookieName), SsrCookieService.safeDecodeURIComponent(cookieValue)); + } + }); + + return allCookies; + } + + /** + * Saves a cookie to the client-side document + * + * @param name + * @param value + * @param options + * @private + * + * @author: Blake Ballard (blakeoxx) + * @since: 16.2.0 + */ + private setClientCookie( + name: string, + value: string, + options: { + expires?: number | Date; + path?: string; + domain?: string; + secure?: boolean; + sameSite?: 'Lax' | 'None' | 'Strict'; + partitioned?: boolean; + } = {} + ): void { + let cookieString: string = encodeURIComponent(name) + '=' + encodeURIComponent(value) + ';'; + + if (options.expires) { + if (typeof options.expires === 'number') { + const dateExpires: Date = new Date(new Date().getTime() + options.expires * 1000 * 60 * 60 * 24); + + cookieString += 'expires=' + dateExpires.toUTCString() + ';'; + } else { + cookieString += 'expires=' + options.expires.toUTCString() + ';'; + } + } + + if (options.path) { + cookieString += 'path=' + options.path + ';'; + } + + if (options.domain) { + cookieString += 'domain=' + options.domain + ';'; + } + + if (options.secure === false && options.sameSite === 'None') { + options.secure = true; + console.warn( + `[ngx-cookie-service] Cookie ${name} was forced with secure flag because sameSite=None.` + + `More details : https://github.com/stevermeister/ngx-cookie-service/issues/86#issuecomment-597720130` + ); + } + if (options.secure) { + cookieString += 'secure;'; + } + + if (!options.sameSite) { + options.sameSite = 'Lax'; + } + + cookieString += 'sameSite=' + options.sameSite + ';'; + + if (options.partitioned) { + cookieString += 'Partitioned;'; + } + + this.document.cookie = cookieString; + } + + /** + * Saves a cookie to the server-side response cookie headers + * + * @param name + * @param value + * @param options + * @private + * + * @author: Blake Ballard (blakeoxx) + * @since: 16.2.0 + */ + private setServerCookie( + name: string, + value: string, + options: { + expires?: number | Date; + path?: string; + domain?: string; + secure?: boolean; + sameSite?: 'Lax' | 'None' | 'Strict'; + partitioned?: boolean; + } = {} + ): void { + const expressOptions: CookieOptions = {}; + + if (options.expires) { + if (typeof options.expires === 'number') { + expressOptions.expires = new Date(new Date().getTime() + options.expires * 1000 * 60 * 60 * 24); + } else { + expressOptions.expires = options.expires; + } + } + + if (options.path) { + expressOptions.path = options.path; + } + + if (options.domain) { + expressOptions.domain = options.domain; + } + + if (options.secure) { + expressOptions.secure = options.secure; + } + + if (options.sameSite) { + expressOptions.sameSite = options.sameSite.toLowerCase() as ('lax' | 'none' | 'strict'); + } + + if (options.partitioned) { + // TODO: Partitioned cookies aren't supported by express yet. See https://github.com/expressjs/express/issues/5275 + // expressOptions.partitioned = options.partitioned; + console.warn( + `[ngx-cookie-service] Cookie ${name} was set with the partitioned option, but partitioned is not yet ` + + `supported by server-side cookies.` + ); + } + + this.response?.cookie(name, value, expressOptions); + } + /** * Return `true` if {@link Document} is accessible, otherwise return `false` * @@ -66,9 +259,8 @@ export class SsrCookieService { * @since: 1.0.0 */ check(name: string): boolean { - name = encodeURIComponent(name); - const regExp: RegExp = SsrCookieService.getCookieRegExp(name); - return regExp.test(this.documentIsAccessible ? this.document.cookie : this.request?.headers.cookie); + const allCookies = this.getCombinedCookies(); + return allCookies.has(name); } /** @@ -81,16 +273,8 @@ export class SsrCookieService { * @since: 1.0.0 */ get(name: string): string { - if (this.check(name)) { - name = encodeURIComponent(name); - - const regExp: RegExp = SsrCookieService.getCookieRegExp(name); - const result: RegExpExecArray = regExp.exec(this.documentIsAccessible ? this.document.cookie : this.request?.headers.cookie); - - return result[1] ? SsrCookieService.safeDecodeURIComponent(result[1]) : ''; - } else { - return ''; - } + const allCookies = this.getCombinedCookies(); + return (allCookies.get(name) || ''); } /** @@ -102,17 +286,8 @@ export class SsrCookieService { * @since: 1.0.0 */ getAll(): { [key: string]: string } { - const cookies: { [key: string]: string } = {}; - const cookieString: any = this.documentIsAccessible ? this.document?.cookie : this.request?.headers.cookie; - - if (cookieString && cookieString !== '') { - cookieString.split(';').forEach((currentCookie) => { - const [cookieName, cookieValue] = currentCookie.split('='); - cookies[SsrCookieService.safeDecodeURIComponent(cookieName.replace(/^ /, ''))] = SsrCookieService.safeDecodeURIComponent(cookieValue); - }); - } - - return cookies; + const allCookies = this.getCombinedCookies(); + return Object.fromEntries(allCookies); } /** @@ -183,10 +358,6 @@ export class SsrCookieService { sameSite?: 'Lax' | 'None' | 'Strict', partitioned?: boolean ): void { - if (!this.documentIsAccessible) { - return; - } - if (typeof expiresOrOptions === 'number' || expiresOrOptions instanceof Date || path || domain || secure || sameSite) { const optionsBody = { expires: expiresOrOptions, @@ -201,50 +372,11 @@ export class SsrCookieService { return; } - let cookieString: string = encodeURIComponent(name) + '=' + encodeURIComponent(value) + ';'; - - const options = expiresOrOptions ? expiresOrOptions : {}; - - if (options.expires) { - if (typeof options.expires === 'number') { - const dateExpires: Date = new Date(new Date().getTime() + options.expires * 1000 * 60 * 60 * 24); - - cookieString += 'expires=' + dateExpires.toUTCString() + ';'; - } else { - cookieString += 'expires=' + options.expires.toUTCString() + ';'; - } - } - - if (options.path) { - cookieString += 'path=' + options.path + ';'; - } - - if (options.domain) { - cookieString += 'domain=' + options.domain + ';'; - } - - if (options.secure === false && options.sameSite === 'None') { - options.secure = true; - console.warn( - `[ngx-cookie-service] Cookie ${name} was forced with secure flag because sameSite=None.` + - `More details : https://github.com/stevermeister/ngx-cookie-service/issues/86#issuecomment-597720130` - ); - } - if (options.secure) { - cookieString += 'secure;'; - } - - if (!options.sameSite) { - options.sameSite = 'Lax'; - } - - cookieString += 'sameSite=' + options.sameSite + ';'; - - if (options.partitioned) { - cookieString += 'Partitioned;'; + if (this.documentIsAccessible) { + this.setClientCookie(name, value, expiresOrOptions); + } else { + this.setServerCookie(name, value, expiresOrOptions); } - - this.document.cookie = cookieString; } /** @@ -260,9 +392,6 @@ export class SsrCookieService { * @since: 1.0.0 */ delete(name: string, path?: string, domain?: string, secure?: boolean, sameSite: 'Lax' | 'None' | 'Strict' = 'Lax'): void { - if (!this.documentIsAccessible) { - return; - } const expiresDate = new Date('Thu, 01 Jan 1970 00:00:01 GMT'); this.set(name, '', { expires: expiresDate, path, domain, secure, sameSite }); } @@ -279,10 +408,6 @@ export class SsrCookieService { * @since: 1.0.0 */ deleteAll(path?: string, domain?: string, secure?: boolean, sameSite: 'Lax' | 'None' | 'Strict' = 'Lax'): void { - if (!this.documentIsAccessible) { - return; - } - const cookies: any = this.getAll(); for (const cookieName in cookies) {