@ima/core
Version:
IMA.js framework for isomorphic javascript application
474 lines (473 loc) • 18 kB
JavaScript
import memoizeOne from 'memoize-one';
import { Storage } from './Storage';
import { GenericError } from '../error/GenericError';
import { Request } from '../router/Request';
import { Response } from '../router/Response';
import { Window } from '../window/Window';
/**
* Implementation note: This is the largest possible safe value that has been
* tested, used to represent "infinity".
*/ const MAX_EXPIRE_DATE = new Date('Fri, 31 Dec 9999 23:59:59 UTC');
/**
* Separator used to separate cookie declarations in the `Cookie` HTTP
* header or the return value of the `document.cookie` property.
*/ const COOKIE_SEPARATOR = '; ';
/**
* Storage of cookies, mirroring the cookies to the current request / response
* at the server side and the `document.cookie` property at the client
* side. The storage caches the cookies internally.
*/ export class CookieStorage extends Storage {
/**
* The window utility used to determine whether the IMA is being run
* at the client or at the server.
*/ _window;
/**
* The current HTTP request. This field is used at the server side.
*/ _request;
/**
* The current HTTP response. This field is used at the server side.
*/ _response;
/**
* The internal storage of entries.
*/ _storage = new Map();
/**
* The overriding cookie attribute values.
*/ _options = {
path: '/',
expires: undefined,
maxAge: undefined,
secure: false,
partitioned: false,
httpOnly: false,
domain: '',
sameSite: 'lax'
};
/**
* Transform encode and decode functions for cookie value.
*/ _transformFunction = {
encode: (value)=>value,
decode: (value)=>value
};
/**
* Memoized function of private parseRawCookies function
*/ #memoParseRawCookies = memoizeOne(this.#parseRawCookies);
static get $dependencies() {
return [
Window,
Request,
Response
];
}
/**
* Filters invalid cookies based on the provided url.
* We try to check validity of the domain based on secure, path and
* domain definitions.
*/ static validateCookieSecurity(cookie, url) {
const { pathname, hostname, protocol } = new URL(url);
const secure = protocol === 'https:';
/**
* Don't allow setting secure cookies without the secure
* defined in the validate options.
*/ if (typeof cookie.options.secure === 'boolean' && secure !== cookie.options.secure) {
return false;
}
/**
* Don't allow setting cookies with a path that doesn't start with
* the path defined in the validate options. Root path is always valid.
*/ if (typeof cookie.options.path === 'string' && cookie.options.path !== '/') {
const pathChunks = pathname.split('/');
const cookiePathChunks = cookie.options.path.split('/');
/**
* Compare the path chunks of the request path and the cookie path.
*/ for(let i = 0; i < cookiePathChunks.length; i++){
/**
* There are no more path chunks to compare, so the cookie path is a
* prefix of the request path.
*/ if (cookiePathChunks[i] === undefined) {
break;
}
/**
* The path chunks don't match, the cookie path is not a prefix of
* the request path.
*/ if (pathChunks[i] !== cookiePathChunks[i]) {
return false;
}
}
}
/**
* Domain security check, we also check for subdomain match.
*/ if (cookie.options.domain) {
const cookieDomain = cookie.options.domain.toLowerCase();
const normalizedCookieDomain = cookieDomain.startsWith('.') ? cookieDomain.slice(1) : cookieDomain;
return normalizedCookieDomain === hostname || hostname.endsWith(normalizedCookieDomain) ? true : false;
}
return true;
}
/**
* Initializes the cookie storage.
*
* @param window The window utility.
* @param request The current HTTP request.
* @param response The current HTTP response.
* @example
* cookie.set('cookie', 'value', { expires: 10 }); // cookie expires
* // after 10s
* cookie.set('cookie'); // delete cookie
*
*/ constructor(window, request, response){
super();
this._window = window;
this._request = request;
this._response = response;
}
/**
* @inheritDoc
*/ init(options = {}, transformFunction = {}) {
this._transformFunction = Object.assign(this._transformFunction, transformFunction);
this._options = Object.assign(this._options, options);
this.parse();
return this;
}
/**
* @inheritDoc
*/ has(name) {
this.parse();
return this._storage.has(name);
}
/**
* @inheritDoc
*/ get(name) {
this.parse();
return this._storage.has(name) ? this._storage.get(name).value : undefined;
}
/**
* @inheritDoc
* @param name The key identifying the storage entry.
* @param value The storage entry value.
* @param options The cookie options. The `maxAge` is the maximum
* age in seconds of the cookie before it will be deleted, the
* `expires` is an alternative to that, specifying the moment
* at which the cookie will be discarded. The `domain` and
* `path` specify the cookie's domain and path. The
* `httpOnly` and `secure` flags set the flags of the
* same name of the cookie.
*/ set(name, value, options = {}) {
options = Object.assign({}, this._options, options);
if (value === undefined) {
// Deletes the cookie
options.maxAge = 0;
options.expires = this.getExpirationAsDate(-1);
} else {
this.recomputeCookieMaxAgeAndExpires(options);
}
value = this.sanitizeCookieValue(value + '');
if (this._window.isClient()) {
document.cookie = this.#generateCookieString(name, value, options);
} else {
this._response.setCookie(name, value, options);
}
this._storage.set(name, {
value,
options
});
return this;
}
/**
* Deletes the cookie identified by the specified name.
*
* @param name Name identifying the cookie.
* @param options The cookie options. The `domain` and
* `path` specify the cookie's domain and path. The
* `httpOnly` and `secure` flags set the flags of the
* same name of the cookie.
* @return This storage.
*/ delete(name, options = {}) {
if (this._storage.has(name)) {
this.set(name, undefined, options);
this._storage.delete(name);
}
return this;
}
/**
* @inheritDoc
*/ clear() {
for (const cookieName of this._storage.keys()){
this.delete(cookieName);
}
this._storage.clear();
return this;
}
/**
* @inheritDoc
*/ keys() {
this.parse();
return this._storage.keys();
}
/**
* @inheritDoc
*/ size() {
this.parse();
return this._storage.size;
}
/**
* Returns all cookies in this storage serialized to a string compatible
* with the `Cookie` HTTP header.
*
* When `url` is provided, the method validates the cookie security based on
* the `url` and the cookie's domain, path, and secure attributes.
*
* @return All cookies in this storage serialized to a string
* compatible with the `Cookie` HTTP header.
*/ getCookiesStringForCookieHeader(url) {
const cookieStrings = [];
for (const cookieName of this._storage.keys()){
const cookieItem = this._storage.get(cookieName);
/**
* Skip cookies that are not secure for the provided url.
*/ if (url && cookieItem && !CookieStorage.validateCookieSecurity(cookieItem, url)) {
continue;
}
cookieStrings.push(this.#generateCookieString(cookieName, cookieItem.value, {}));
}
return cookieStrings.join(COOKIE_SEPARATOR);
}
/**
* Parses cookies from the provided `Set-Cookie` HTTP header value.
*
* When `url` is provided, the method validates the cookie security based on
* the `url` and the cookie's domain, path, and secure attributes.
*
* The parsed cookies will be set to the internal storage, and the current
* HTTP response (via the `Set-Cookie` HTTP header) if at the server
* side, or the browser (via the `document.cookie` property).
*
* @param cookiesString The value of the `Set-Cookie` HTTP
* header. When there are multiple cookies, the value can be
* provided as an array of strings.
*/ parseFromSetCookieHeader(cookiesString, url) {
const cookiesArray = Array.isArray(cookiesString) ? cookiesString : [
cookiesString
];
for (const cookie of cookiesArray){
const cookieItem = this.#extractCookie(cookie);
/**
* Skip cookies that are not secure for the provided url.
*/ if (url && cookieItem && !CookieStorage.validateCookieSecurity(cookieItem, url)) {
continue;
}
if (typeof cookieItem.name === 'string') {
this.set(cookieItem.name, cookieItem.value, cookieItem.options);
}
}
}
/**
* Parses cookies from a cookie string and sets the parsed cookies to the
* internal storage.
*
* The method obtains the cookie string from the request's `Cookie`
* HTTP header when used at the server side, and the `document.cookie`
* property at the client side.
*/ parse() {
const cookiesNames = this.#memoParseRawCookies(this._window.isClient() ? document.cookie : this._request.getCookieHeader());
// remove cookies from storage, which were not parsed
for (const storageCookieName of this._storage.keys()){
const index = cookiesNames.indexOf(storageCookieName);
if (index === -1) {
this._storage.delete(storageCookieName);
}
}
}
/**
* Sanitize cookie value by rules in
* (@see http://tools.ietf.org/html/rfc6265#section-4r.1.1). Erase all
* invalid characters from cookie value.
*
* @param value Cookie value
* @return Sanitized value
*/ sanitizeCookieValue(value) {
let sanitizedValue = '';
if (typeof value !== 'string') {
return sanitizedValue;
}
for(let keyChar = 0; keyChar < value.length; keyChar++){
const charCode = value.charCodeAt(keyChar);
const char = value[keyChar];
const isValid = charCode >= 33 && charCode <= 126 && char !== '"' && char !== ';' && char !== '\\';
if (isValid) {
sanitizedValue += char;
} else {
if ($Debug) {
throw new GenericError(`Invalid char ${char} code ${charCode} in ${value}. ` + `Dropping the invalid character from the cookie's ` + `value.`, {
value,
charCode,
char
});
}
}
}
return sanitizedValue;
}
/**
* Recomputes cookie's attributes maxAge and expires between each other.
*
* @param options Cookie attributes. Only the attributes listed in the
* type annotation of this field are supported. For documentation
* and full list of cookie attributes see
* http://tools.ietf.org/html/rfc2965#page-5
*/ recomputeCookieMaxAgeAndExpires(options) {
if (options.maxAge || options.expires) {
options.expires = this.getExpirationAsDate(options.maxAge || options.expires);
}
if (!options.maxAge && options.expires) {
options.maxAge = Math.floor((options.expires.valueOf() - Date.now()) / 1000);
}
}
/**
* Converts the provided cookie expiration to a `Date` instance.
*
* @param expiration Cookie expiration in seconds
* from now, or as a string compatible with the `Date`
* constructor.
* @return Cookie expiration as a `Date` instance.
*/ getExpirationAsDate(expiration) {
if (expiration instanceof Date) {
return expiration;
}
if (typeof expiration === 'number') {
return expiration === Infinity ? MAX_EXPIRE_DATE : new Date(Date.now() + expiration * 1000);
}
return expiration ? new Date(expiration) : MAX_EXPIRE_DATE;
}
#parseRawCookies(rawCookies) {
const cookiesArray = rawCookies ? rawCookies.split(COOKIE_SEPARATOR) : [];
const cookiesNames = [];
for(let i = 0; i < cookiesArray.length; i++){
const cookie = this.#extractCookie(cookiesArray[i]);
if (typeof cookie.name === 'string') {
// if cookie already exists in storage get its old options
let oldCookieOptions = {};
if (this._storage.has(cookie.name)) {
oldCookieOptions = this._storage.get(cookie.name).options;
}
cookie.options = Object.assign({}, this._options, oldCookieOptions, cookie.options // new cookie options (if any)
);
cookiesNames.push(cookie.name);
// add new cookie or update existing one
this._storage.set(cookie.name, {
value: this.sanitizeCookieValue(cookie.value),
options: cookie.options
});
}
}
return cookiesNames;
}
/**
* Creates a copy of the provided word (or text) that has its first
* character converted to lower case.
*
* @param word The word (or any text) that should have its first
* character converted to lower case.
* @return A copy of the provided string with its first character
* converted to lower case.
*/ #firstLetterToLowerCase(word) {
return word.charAt(0).toLowerCase() + word.substring(1);
}
/**
* Generates a string representing the specified cookie, usable either
* with the `document.cookie` property or the `Set-Cookie` HTTP
* header.
*
* (Note that the `Cookie` HTTP header uses a slightly different
* syntax.)
*
* @param name The cookie name.
* @param value The cookie value, will be
* converted to string.
* @param options Cookie attributes. Only the attributes listed in the
* type annotation of this field are supported. For documentation
* and full list of cookie attributes see
* http://tools.ietf.org/html/rfc2965#page-5
* @return A string representing the cookie. Setting this string
* to the `document.cookie` property will set the cookie to
* the browser's cookie storage.
*/ #generateCookieString(name, value, options) {
let cookieString = name + '=' + this._transformFunction.encode(value);
cookieString += options.domain ? ';Domain=' + options.domain : '';
cookieString += options.path ? ';Path=' + options.path : '';
cookieString += options.expires ? ';Expires=' + options.expires.toUTCString() : '';
cookieString += options.maxAge ? ';Max-Age=' + options.maxAge : '';
cookieString += options.httpOnly ? ';HttpOnly' : '';
cookieString += options.secure ? ';Secure' : '';
cookieString += options.partitioned ? ';Partitioned' : '';
cookieString += options.sameSite ? ';SameSite=' + options.sameSite : '';
return cookieString;
}
/**
* Extract cookie name, value and options from cookie string.
*
* @param cookieString The value of the `Set-Cookie` HTTP
* header.
*/ #extractCookie(cookieString) {
const cookieOptions = {};
let cookieName;
let cookieValue;
cookieString.split(COOKIE_SEPARATOR.trim()).forEach((pair, index)=>{
const [name, value] = this.#extractNameAndValue(pair, index);
if (!name) {
return;
}
if (index === 0) {
cookieName = name;
cookieValue = value;
} else {
Object.assign(cookieOptions, {
[name]: value
});
}
});
return {
name: cookieName,
value: cookieValue,
options: cookieOptions
};
}
/**
* Extract name and value for defined pair and pair index.
*/ #extractNameAndValue(pair, pairIndex) {
const separatorIndexEqual = pair.indexOf('=');
let name = '';
let value = null;
if (pairIndex === 0 && separatorIndexEqual < 0) {
return [
null,
null
];
}
if (separatorIndexEqual < 0) {
name = pair.trim();
value = true;
} else {
name = pair.substring(0, separatorIndexEqual).trim();
value = this._transformFunction.decode(pair.substring(separatorIndexEqual + 1).trim());
// erase quoted values
if ('"' === value[0]) {
value = value.slice(1, -1);
}
if (name === 'Expires') {
value = this.getExpirationAsDate(value);
}
if (name === 'Max-Age') {
name = 'maxAge';
value = parseInt(value, 10);
}
}
if (pairIndex !== 0) {
name = this.#firstLetterToLowerCase(name);
}
return [
name,
value
];
}
}
//# sourceMappingURL=CookieStorage.js.map