Skip to content

Commit

Permalink
feat(ssr): adds server cookie output via response
Browse files Browse the repository at this point in the history
Resolves stevermeister#266

Cherry-picked from commit 123b698
  • Loading branch information
blakeoxx committed May 16, 2024
1 parent 2118eeb commit b127931
Show file tree
Hide file tree
Showing 2 changed files with 207 additions and 79 deletions.
3 changes: 3 additions & 0 deletions projects/ngx-cookie-service-ssr/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@
},
{
"name": "IceBreakerG"
},
{
"name": "Blake Ballard (blakeoxx)"
}
],
"repository": {
Expand Down
283 changes: 204 additions & 79 deletions projects/ngx-cookie-service-ssr/src/lib/ssr-cookie.service.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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<string, string> {
const cookies = new Map<string, string>;

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<string, string> {
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`
*
Expand All @@ -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);
}

/**
Expand All @@ -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) || '');
}

/**
Expand All @@ -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);
}

/**
Expand Down Expand Up @@ -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,
Expand All @@ -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;
}

/**
Expand All @@ -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 });
}
Expand All @@ -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) {
Expand Down

0 comments on commit b127931

Please sign in to comment.