Commit df7bf227 authored by Ryan Diehl's avatar Ryan Diehl

feat(cookie): add cookie implementation

based on ngx-cookie, modernized to use providedIn: root
parent ebf706af
......@@ -11,6 +11,14 @@ Must be registered at the same level as `HttpClientModule`
- `CacheInterceptor`
- `const NO_CACHE_HEADER = 'X-No-Cache'`
## Cookies
`CookieService` adds support for cookie handling. This functionality was
ported from [ngx-cookie](https://github.com/salemdar/ngx-cookie) and then modernized.
NOTE: @psu/security currently imports ngx-cookie and calls CookieModule.forRoot() (ngx-cookie version). Therefore any application that uses @psu/security is unknowingly importing
CookieModule from ngx-cookie, so your app will still need that dependency.
## Focus Directive
Autofocuses the host element
......
export { CacheInterceptor, NO_CACHE_HEADER } from './lib/cache/cache.interceptor';
export * from './lib/cookie/';
export { FocusDirective } from './lib/focus/focus.directive';
export { FocusModule } from './lib/focus/focus.module';
export { HttpErrorService } from './lib/http-error/http-error.service';
......
/**
* @name CookieOptionsArgs
* @description
*
* Object containing default options to pass when setting cookies.
*
* The object may have following properties:
*
* - **path** - {string} - The cookie will be available only for this path and its
* sub-paths. By default, this is the URL that appears in your `<base>` tag.
* - **domain** - {string} - The cookie will be available only for this domain and
* its sub-domains. For security reasons the user agent will not accept the cookie
* if the current domain is not a sub-domain of this domain or equal to it.
* - **expires** - {string|Date} - String of the form "Wdy, DD Mon YYYY HH:MM:SS GMT"
* or a Date object indicating the exact date/time this cookie will expire.
* - **secure** - {boolean} - If `true`, then the cookie will only be available through a
* secured connection.
* - **httpOnly** - {boolean} - If `true`, then the cookie will be set with the `HttpOnly`
* flag, and will only be accessible from the remote server. Helps to prevent against
* XSS attacks.
* - **storeUnencoded** - {boolean} - If `true`, then the cookie value will not be encoded and
* will be stored as provided.
*/
export interface CookieOptions {
path?: string;
domain?: string;
expires?: string | Date;
secure?: boolean;
httpOnly?: boolean;
storeUnencoded?: boolean;
}
import { Injector } from '@angular/core';
import { getTestBed } from '@angular/core/testing';
import { CookieOptions } from './cookie-options.model';
import { CookieService } from './cookie.service';
describe('CookieService', () => {
let injector: Injector;
let cookieService: CookieService;
beforeEach(() => {
injector = getTestBed();
cookieService = injector.get(CookieService);
});
afterEach(() => {
cookieService.removeAll();
injector = undefined;
cookieService = undefined;
});
it('is defined', () => {
expect(CookieService).toBeDefined();
expect(cookieService).toBeDefined();
expect(cookieService instanceof CookieService).toBeTruthy();
});
it('should return undefined a non-existent cookie', () => {
const key = 'nonExistentCookieKey';
expect(cookieService.get(key)).toBeUndefined();
});
it('should set and get a simple cookie', () => {
const key = 'testCookieKey';
const value = 'testCookieValue';
cookieService.put(key, value);
expect(cookieService.get(key)).toBe(value);
});
it('should get as string with getObject if cannot deserialize', () => {
const key = 'testCookieKey';
const value = 'testCookieValue';
cookieService.put(key, value);
expect(cookieService.getObject(key)).toBe(value);
});
it('should get empty cookie', () => {
const key = 'testCookieKey';
const value = '';
cookieService.put(key, value);
expect(cookieService.getObject(key)).toBe(value);
});
it('should edit a simple cookie', () => {
const key = 'testCookieKey';
const oldValue = 'testCookieValue';
const newValue = 'testCookieValueNew';
cookieService.put(key, oldValue);
expect(cookieService.get(key)).toBe(oldValue);
cookieService.put(key, newValue);
expect(cookieService.get(key)).toBe(newValue);
});
it('should remove a cookie', () => {
const key = 'testCookieKey';
const value = 'testCookieValue';
cookieService.put(key, value);
expect(cookieService.get(key)).toBe(value);
cookieService.remove(key);
expect(cookieService.get(key)).toBeUndefined();
});
it('should set and get an object cookie', () => {
const key = 'testCookieKey';
const value = { key1: 'value1', key2: 'value2' };
cookieService.putObject(key, value);
expect(cookieService.getObject(key)).toEqual(value);
});
it('should set and get multiple cookies', () => {
const simpleCookies = [
{ key: 'key1', value: 'value1' },
{ key: 'key2', value: 'value2' },
{ key: 'key3', value: 'value3' }
];
const objectCookies = [
{ key: 'keyO1', value: { keyO1_1: 'valueO1_1', keyO1_2: 'valueO1_2' } },
{ key: 'keyO2', value: { keyO2_1: 'valueO2_1', keyO2_2: 'valueO2_2' } },
{ key: 'keyO3', value: { keyO3_1: 'valueO3_1', keyO3_2: 'valueO3_2' } }
];
const result: any = {};
simpleCookies.forEach(c => {
result[c.key] = c.value;
cookieService.put(c.key, c.value);
});
objectCookies.forEach(c => {
result[c.key] = JSON.stringify(c.value);
cookieService.putObject(c.key, c.value);
});
expect(cookieService.getAll()).toEqual(result);
});
it('should remove all cookies', () => {
const simpleCookies = [
{ key: 'key1', value: 'value1' },
{ key: 'key2', value: 'value2' },
{ key: 'key3', value: 'value3' }
];
const objectCookies = [
{ key: 'keyO1', value: { keyO1_1: 'valueO1_1', keyO1_2: 'valueO1_2' } },
{ key: 'keyO2', value: { keyO2_1: 'valueO2_1', keyO2_2: 'valueO2_2' } },
{ key: 'keyO3', value: { keyO3_1: 'valueO3_1', keyO3_2: 'valueO3_2' } }
];
simpleCookies.forEach(c => {
cookieService.put(c.key, c.value);
});
objectCookies.forEach(c => {
cookieService.putObject(c.key, c.value);
});
cookieService.removeAll();
expect(cookieService.getAll()).toEqual({});
});
it('should remove all cookies when passing options', () => {
const optionCookies = {
path: '/',
domain: 'localhost',
expires: new Date(),
secure: false
};
const simpleCookies = [
{ key: 'key1', value: 'value1', option: optionCookies },
{ key: 'key2', value: 'value2', option: optionCookies },
{ key: 'key3', value: 'value3', option: optionCookies }
];
const objectCookies = [
{ key: 'keyO1', value: { keyO1_1: 'valueO1_1', keyO1_2: 'valueO1_2' }, option: optionCookies },
{ key: 'keyO2', value: { keyO2_1: 'valueO2_1', keyO2_2: 'valueO2_2' }, option: optionCookies },
{ key: 'keyO3', value: { keyO3_1: 'valueO3_1', keyO3_2: 'valueO3_2' }, option: optionCookies }
];
simpleCookies.forEach(c => {
cookieService.put(c.key, c.value, c.option);
});
objectCookies.forEach(c => {
cookieService.putObject(c.key, c.value, c.option);
});
cookieService.removeAll(optionCookies);
expect(cookieService.getAll()).toEqual({});
});
it('should return undefined for nonexisting cookies', () => {
expect(cookieService.get('nonexistingCookie')).toBeUndefined();
});
it('should change the settings', () => {
const key = 'testCookieKey';
const value = 'testCookieValue';
const opts: CookieOptions = {
expires: new Date('2030-07-19')
};
cookieService.put(key, value, opts);
expect(cookieService.get(key)).toBe(value);
});
it('should store unencoded cookie values if requested', () => {
const key = 'testCookieKey';
const value = 'testCookieValue=unencoded';
const opts: CookieOptions = {
storeUnencoded: true
};
cookieService.put(key, value, opts);
expect(document.cookie).toBe(`${key}=${value}`);
expect(cookieService.get(key)).toBe(value);
});
});
import { APP_BASE_HREF } from '@angular/common';
import { Injectable, Injector } from '@angular/core';
import { CookieOptions } from './cookie-options.model';
import { isBlank, isString, mergeOptions, safeDecodeURIComponent, safeJsonParse } from './utils';
declare interface Document {
cookie: string;
}
declare const document: Document;
export interface ICookieService {
get(key: string): string;
getObject(key: string): Object;
getAll(): Object;
put(key: string, value: string, options?: CookieOptions): void;
putObject(key: string, value: Object, options?: CookieOptions): void;
remove(key: string, options?: CookieOptions): void;
removeAll(options?: CookieOptions): void;
}
@Injectable({ providedIn: 'root' })
export class CookieService implements ICookieService {
protected options: CookieOptions;
protected get cookieString(): string {
return document.cookie || '';
}
protected set cookieString(val: string) {
document.cookie = val;
}
constructor(private injector: Injector) {
this.options = {
path: this.injector.get(APP_BASE_HREF, '/'),
domain: null,
expires: null,
secure: false,
httpOnly: false
};
}
/**
* @name CookieService#get
*
* @description
* Returns the value of given cookie key.
*
* @param key Id to use for lookup.
* @returns Raw cookie value.
*/
get(key: string): string {
return (<any>this._cookieReader())[key];
}
/**
* @name CookieService#getObject
*
* @description
* Returns the deserialized value of given cookie key.
*
* @param key Id to use for lookup.
* @returns Deserialized cookie value.
*/
getObject(key: string): Object {
const value = this.get(key);
return value ? safeJsonParse(value) : value;
}
/**
* @name CookieService#getAll
*
* @description
* Returns a key value object with all the cookies.
*
* @returns All cookies
*/
getAll(): Object {
return <any>this._cookieReader();
}
/**
* @name CookieService#put
*
* @description
* Sets a value for given cookie key.
*
* @param key Id for the `value`.
* @param value Raw value to be stored.
* @param options (Optional) Options object.
*/
put(key: string, value: string, options?: CookieOptions) {
this._cookieWriter()(key, value, options);
}
/**
* @name CookieService#putObject
*
* @description
* Serializes and sets a value for given cookie key.
*
* @param key Id for the `value`.
* @param value Value to be stored.
* @param options (Optional) Options object.
*/
putObject(key: string, value: Object, options?: CookieOptions) {
this.put(key, JSON.stringify(value), options);
}
/**
* @name CookieService#remove
*
* @description
* Remove given cookie.
*
* @param key Id of the key-value pair to delete.
* @param options (Optional) Options object.
*/
remove(key: string, options?: CookieOptions): void {
this._cookieWriter()(key, undefined, options);
}
/**
* @name CookieService#removeAll
*
* @description
* Remove all cookies.
*/
removeAll(options?: CookieOptions): void {
const cookies = this.getAll();
Object.keys(cookies).forEach(key => {
this.remove(key, options);
});
}
private _cookieReader(): Object {
let lastCookies = {};
let lastCookieString = '';
let cookieArray: string[], cookie: string, i: number, index: number, name: string;
const currentCookieString = this.cookieString;
if (currentCookieString !== lastCookieString) {
lastCookieString = currentCookieString;
cookieArray = lastCookieString.split('; ');
lastCookies = {};
for (i = 0; i < cookieArray.length; i++) {
cookie = cookieArray[i];
index = cookie.indexOf('=');
if (index > 0) {
// ignore nameless cookies
name = safeDecodeURIComponent(cookie.substring(0, index));
// the first value that is seen for a cookie is the most
// specific one. values for the same cookie name that
// follow are for less specific paths.
if (isBlank((<any>lastCookies)[name])) {
(<any>lastCookies)[name] = safeDecodeURIComponent(cookie.substring(index + 1));
}
}
}
}
return lastCookies;
}
private _cookieWriter() {
const that = this;
return function(name: string, value: string, options?: CookieOptions) {
that.cookieString = that._buildCookieString(name, value, options);
};
}
private _buildCookieString(name: string, value: string, options?: CookieOptions): string {
const opts: CookieOptions = mergeOptions(this.options, options);
let expires: any = opts.expires;
if (isBlank(value)) {
expires = 'Thu, 01 Jan 1970 00:00:00 GMT';
value = '';
}
if (isString(expires)) {
expires = new Date(expires);
}
const cookieValue = opts.storeUnencoded ? value : encodeURIComponent(value);
let str = encodeURIComponent(name) + '=' + cookieValue;
str += opts.path ? ';path=' + opts.path : '';
str += opts.domain ? ';domain=' + opts.domain : '';
str += expires ? ';expires=' + expires.toUTCString() : '';
str += opts.secure ? ';secure' : '';
str += opts.httpOnly ? '; HttpOnly' : '';
// per http://www.ietf.org/rfc/rfc2109.txt browser must allow at minimum:
// - 300 cookies
// - 20 cookies per unique domain
// - 4096 bytes per cookie
const cookieLength = str.length + 1;
if (cookieLength > 4096) {
console.log(
`Cookie \'${name}\' possibly not set or overflowed because it was too large (${cookieLength} > 4096 bytes)!`
);
}
return str;
}
}
export { CookieOptions } from './cookie-options.model';
export { CookieService } from './cookie.service';
import { CookieOptions } from './cookie-options.model';
export function isBlank(obj: any): boolean {
return obj === undefined || obj === null;
}
export function isPresent(obj: any): boolean {
return obj !== undefined && obj !== null;
}
export function isString(obj: any): obj is string {
return typeof obj === 'string';
}
export function mergeOptions(oldOptions: CookieOptions, newOptions?: CookieOptions): CookieOptions {
if (!newOptions) {
return oldOptions;
}
return {
path: isPresent(newOptions.path) ? newOptions.path : oldOptions.path,
domain: isPresent(newOptions.domain) ? newOptions.domain : oldOptions.domain,
expires: isPresent(newOptions.expires) ? newOptions.expires : oldOptions.expires,
secure: isPresent(newOptions.secure) ? newOptions.secure : oldOptions.secure,
httpOnly: isPresent(newOptions.httpOnly) ? newOptions.httpOnly : oldOptions.httpOnly,
storeUnencoded: isPresent(newOptions.storeUnencoded) ? newOptions.storeUnencoded : oldOptions.storeUnencoded
};
}
export function safeDecodeURIComponent(str: string) {
try {
return decodeURIComponent(str);
} catch (e) {
return str;
}
}
export function safeJsonParse(str: string) {
try {
return JSON.parse(str);
} catch (e) {
return str;
}
}
import { LocalStorageService } from './local-storage.service';
import { TestBed } from '@angular/core/testing';
import { CookieService } from '../cookie';
import { WindowService } from '../window/window.service';
import { CookieService, CookieModule } from 'ngx-cookie';
import { LocalStorageService } from './local-storage.service';
class WindowServiceStubWithCookies {
get nativeWindow(): any {
......@@ -66,7 +66,6 @@ describe('LocalStorageService', () => {
function setup(wnd: any): void {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [CookieModule.forRoot()],
providers: [LocalStorageService, CookieService, { provide: WindowService, useClass: wnd }]
});
service = TestBed.get(LocalStorageService);
......
import { Injectable } from '@angular/core';
import { CookieService } from '../cookie/cookie.service';
import { WindowService } from '../window/window.service';
import { CookieService } from 'ngx-cookie';
interface StorageContainer {
clear(): void;
......
......@@ -5,7 +5,6 @@
"@angular/common": "^8.0.0",
"@angular/core": "^8.0.0",
"@angular/cdk": "^8.2.3",
"ngx-cookie": "^4.1.2",
"ramda": "^0.27.0",
"short-uuid": "^3.1.1"
},
......
......@@ -1271,11 +1271,6 @@
tree-kill "1.2.1"
webpack-sources "1.4.3"
"@nguniversal/express-engine@^8.1.1":
version "8.1.1"
resolved "https://nexus.ci.psu.edu/repository/npm-all/@nguniversal/express-engine/-/express-engine-8.1.1.tgz#253f2a484764cf183a679847a0c25c52d5b25ab9"
integrity sha512-LKfNnKb1BU1IyI/U7LcDgU744ptrn7fTYb3QqHAlGaozHUNRjXpNuBdkrcAS5+8UAhRlTllpSt1c+TmY2/tomA==
"@nodelib/fs.scandir@2.1.3":
version "2.1.3"
resolved "https://nexus.ci.psu.edu/repository/npm-all/@nodelib/fs.scandir/-/fs.scandir-2.1.3.tgz#3a582bdb53804c6ba6d146579c46e52130cf4a3b"
......@@ -8153,13 +8148,6 @@ ng-packagr@^5.4.0:
terser "^4.1.2"
update-notifier "^3.0.0"
ngx-cookie@^4.1.2:
version "4.1.2"
resolved "https://nexus.ci.psu.edu/repository/npm-all/ngx-cookie/-/ngx-cookie-4.1.2.tgz#ab2fafc6ee7f23b7ad92f8427dc714894e60aa31"
integrity sha512-BU3q+116mSQZvf8WsnKDxyWFy10LtxSvZz1YIjD7pmaSFpiKdWmHTHn0qLgm3OoIL9TfInQ7Ij46rKJWPD+4Kw==
dependencies:
tslib "^1.9.0"
nice-try@^1.0.4:
version "1.0.5"
resolved "https://nexus.ci.psu.edu/repository/npm-all/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366"
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment