diff --git a/README.md b/README.md index b93ae10..8eb1dd6 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,10 @@ Using the [`crypto-js`](https://github.com/brix/crypto-js) library as an encrypt - [NextJS](#nextjs) - [AsyncEncryptStorage](#asyncencryptstorage) - [AWS Amplify](#aws-amplify) + - [Cookie](#cookie) + - [_set_](#set) + - [_get_](#get) + - [_remove_](#remove) - [State Management Persisters](#state-management-persisters) - [_vuex-persist_](#vuex-persist) - [_redux-persist_](#redux-persist) @@ -60,7 +64,7 @@ Using the [`crypto-js`](https://github.com/brix/crypto-js) library as an encrypt ## Features -- Save encrypted data in `localStorage` and `sessionStorage` +- Save encrypted data in `localStorage`, `sessionStorage` and `cookies` - Recover encrypted data with `get` functions - Use in the same way as native `Web Storage` (localStorage and sessionStorage) - If you use the `stateManagementUse` option, the data acquired in `get` functions will `not` have their return transformed into `Javascript objects`. @@ -705,6 +709,95 @@ async function getDecryptedValue('key'): Promise { } ``` +### Cookie + +Encryptstorage can also be used to encrypt data in cookies. See below for ways to use it. + +#### _set_ + +Set a `encrypted` cookie value passed by parameter. + +```typescript +import { EncryptStorage } from 'encrypt-storage'; +const encryptStorage = new EncryptStorage('secret-key-value', { + prefix: '@encrypt-storage', +}); + +encryptStorage.cookie.set('any-key', { value: 'any-value' }); + +// document.cookie +// any-key=U2FsdGVkX1/2KEwOH+w4QaIcyq5521ZXB5pqw... +``` + +You can pass parameters to the set method, which are normally used in cookies. View params in [CookieOptions](./src/types.ts#L62). + +```typescript +import { EncryptStorage } from 'encrypt-storage'; +const encryptStorage = new EncryptStorage('secret-key-value', { + prefix: '@encrypt-storage', +}); + +encryptStorage.cookie.set( + 'any-key', + { value: 'any-value' }, + { + path: '/', + domain: 'example.com', + expires: new Date(Date.now() + 86400000), + secure: true, + sameSite: 'strict', + }, +); + +// document.cookie +// any-key=U2FsdGVkX1/2KEwOH+w4QaIcyq5521ZXB5pqw; path=/; domain=example.com; expires=Tue, 24 Dec 2024 18:51:07 GMT; secure +``` + +#### _get_ + +Set a `encrypted` cookie value passed by parameter. + +```typescript +import { EncryptStorage } from 'encrypt-storage'; +const encryptStorage = new EncryptStorage('secret-key-value', { + prefix: '@encrypt-storage', +}); + +const value = { value: 'any-value' }; + +encryptStorage.cookie.set('any-key', value); + +// document.cookie +// any-key=U2FsdGVkX1/2KEwOH+w4QaIcyq5521ZXB5pqw... + +const decryptedValue = encryptStorage.cookie.get('any-key'); + +// { value: 'any-value' } +``` + +#### _remove_ + +Set a `encrypted` cookie value passed by parameter. + +```typescript +import { EncryptStorage } from 'encrypt-storage'; +const encryptStorage = new EncryptStorage('secret-key-value', { + prefix: '@encrypt-storage', +}); + +const value = { value: 'any-value' }; + +encryptStorage.cookie.set('any-key', value); + +// document.cookie +// any-key=U2FsdGVkX1/2KEwOH+w4QaIcyq5521ZXB5pqw... + +encryptStorage.cookie.remove('any-key'); + +// document.cookie +// '' +``` + ### AWS Amplify In the case of `aws-amplify`, if you want to use the facility of not needing to use `JSON.parse` in the rest of the application, prefer to create an instance within the `amplify` configuration file, as follows: diff --git a/__tests__/index.spec.ts b/__tests__/index.spec.ts index c57fab1..2499277 100644 --- a/__tests__/index.spec.ts +++ b/__tests__/index.spec.ts @@ -1,8 +1,10 @@ import { test1 } from '../src/experiments/test-1'; import { test2 } from '../src/experiments/test-2'; import { test3 } from '../src/experiments/test-3'; +import { test4 } from '../src/experiments/test-4'; describe('TestSuite', () => { + test4(); test1(); test2(); test3(); diff --git a/package.json b/package.json index 219a241..158e447 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "encrypt-storage", - "version": "2.13.04", + "version": "2.14.00", "description": "Wrapper for encrypted localStorage and sessionStorage in browser", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/encrypt-storage.ts b/src/encrypt-storage.ts index be3b2d7..3d88a44 100644 --- a/src/encrypt-storage.ts +++ b/src/encrypt-storage.ts @@ -6,6 +6,9 @@ import { EncryptStorageOptions, EncryptStorageInterface, RemoveFromPatternOptions, + CookieInterface, + CookieOptions, + RemoveCookieOptions, } from './types'; import { getEncryptation, hashSHA256, hashMD5 } from './utils'; @@ -125,7 +128,10 @@ export class EncryptStorage implements EncryptStorageInterface { } } - public getItem(key: string, doNotDecrypt = false): T | undefined { + public getItem( + key: string, + doNotDecrypt = false, + ): DataType | undefined { const decryptValues = this.#doNotEncryptValues || doNotDecrypt; const storageKey = this.#getKey(key); const item = this.storage?.getItem(storageKey); @@ -143,7 +149,7 @@ export class EncryptStorage implements EncryptStorageInterface { value: decryptedValue, }); } - return decryptedValue as unknown as T; + return decryptedValue as unknown as DataType; } try { @@ -159,8 +165,8 @@ export class EncryptStorage implements EncryptStorageInterface { }); } - return value as T; - } catch (error) { + return value as DataType; + } catch { if (this.#notifyHandler && !this.#multiple) { this.#notifyHandler({ type: 'get', @@ -168,7 +174,7 @@ export class EncryptStorage implements EncryptStorageInterface { value: decryptedValue, }); } - return decryptedValue as unknown as T; + return decryptedValue as unknown as DataType; } } @@ -378,12 +384,12 @@ export class EncryptStorage implements EncryptStorageInterface { return encryptedValue; } - public decryptValue(value: string): T { + public decryptValue(value: string): DataType { const decryptedValue = this.#encryptation.decrypt(value); return ( this.#doNotParseValues ? decryptedValue : JSON.parse(decryptedValue) - ) as T; + ) as DataType; } public hash(value: string): string { @@ -393,6 +399,110 @@ export class EncryptStorage implements EncryptStorageInterface { public md5Hash(value: string): string { return hashMD5(value, secret.get(this)); } + + public cookie: CookieInterface = { + set: (key: string, value: any, options?: CookieOptions): void => { + if ( + typeof document === 'undefined' || + typeof document.cookie === 'undefined' || + typeof window === 'undefined' + ) { + return; + } + let interntValue = this.#doNotParseValues ? value : JSON.stringify(value); + + if (!this.#doNotEncryptValues) { + interntValue = this.encryptValue(interntValue); + } + + let cookieString = `${encodeURIComponent(this.#getKey(key))}=${encodeURIComponent(interntValue)}`; + + if (options?.expires) { + const expires = + options.expires instanceof Date + ? options.expires.toUTCString() + : new Date(Date.now() + options.expires * 1000).toUTCString(); + cookieString += `; expires=${expires}`; + } + + if (options?.path) { + cookieString += `; path=${options.path}`; + } + + if (options?.domain) { + cookieString += `; domain=${options.domain}`; + } + + if (options?.secure) { + cookieString += `; secure`; + } + if (options?.sameSite) { + cookieString += `; samesite=${options.sameSite}`; + } + + document.cookie = cookieString; + + if (this.#notifyHandler) { + this.#notifyHandler({ + type: 'set:cookie', + key, + value: undefined, + }); + } + }, + get: (key: string): DataType | null => { + if ( + typeof document === 'undefined' || + typeof document.cookie === 'undefined' || + typeof window === 'undefined' + ) { + return null; + } + + const match = document.cookie.match( + new RegExp(`(?:^|; )${encodeURIComponent(this.#getKey(key))}=([^;]*)`), + ); + + let internValue = match ? match[1] : null; + + if (!this.#doNotEncryptValues && internValue) { + internValue = this.decryptValue(decodeURIComponent(internValue)); + } + + if (this.#doNotParseValues) { + return internValue as unknown as DataType; + } + + if (this.#notifyHandler) { + this.#notifyHandler({ + type: 'get:cookie', + key, + value: undefined, + }); + } + + return internValue ? (JSON.parse(internValue) as DataType) : null; + }, + remove: (key: string, options: RemoveCookieOptions = {}): void => { + if ( + typeof document === 'undefined' || + typeof document.cookie === 'undefined' || + typeof window === 'undefined' + ) { + return; + } + + this.cookie.set(this.#getKey(key), '', { ...options, expires: -1 }); + + if (this.#notifyHandler) { + this.#notifyHandler({ + type: 'remove:cookie', + key, + value: undefined, + }); + } + }, + }; } /* istanbul ignore next */ diff --git a/src/experiments/test-4.ts b/src/experiments/test-4.ts new file mode 100644 index 0000000..82577ab --- /dev/null +++ b/src/experiments/test-4.ts @@ -0,0 +1,238 @@ +/** + * @jest-environment node + */ + +/* eslint-disable import/no-unresolved */ +/* eslint-disable import/no-extraneous-dependencies */ +import 'jest-localstorage-mock'; +import faker from 'faker'; + +import { EncryptStorage } from '..'; +import { + CookieOptions, + EncryptStorageOptions, + NotifyHandlerParams, +} from '../types'; + +interface makeSutParams extends EncryptStorageOptions { + secretKey?: string; + noOptions?: boolean; +} + +const mockNotify = { + mockedFn: jest.fn().mockImplementation((params: NotifyHandlerParams) => { + return params; + }), +}; + +const makeSut = ( + params: makeSutParams = {} as makeSutParams, +): EncryptStorage => { + const { + prefix, + storageType, + stateManagementUse, + noOptions, + encAlgorithm, + doNotParseValues = false, + notifyHandler = mockNotify.mockedFn, + secretKey = faker.random.alphaNumeric(10), + } = params; + const options = noOptions + ? undefined + : { + prefix, + storageType, + stateManagementUse, + encAlgorithm, + notifyHandler, + doNotParseValues, + }; + return new EncryptStorage(secretKey, options); +}; + +let cookieSetSpy: jest.SpyInstance; +let cookieGetSpy: jest.SpyInstance; + +export const test4 = () => + describe('EncryptStprage cookie', () => { + beforeAll(() => { + cookieSetSpy = jest.spyOn(document, 'cookie', 'set'); + cookieSetSpy?.mockImplementation(() => undefined); + cookieGetSpy = jest.spyOn(document, 'cookie', 'get'); + cookieGetSpy?.mockImplementation(() => undefined); + }); + + beforeEach(() => { + Object.defineProperty(document, 'cookie', { + writable: true, + value: '', + }); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should enshure cookie.set not been called', () => { + const safeStorage = makeSut(); + const key = faker.random.word(); + const value = { value: faker.random.word() }; + + (document.cookie as any) = undefined; + + safeStorage.cookie.set(key, value); + expect(document.cookie).toEqual(undefined); + }); + + it('should enshure cookie.get not been called', () => { + const safeStorage = makeSut(); + const key = faker.random.word(); + const value = { value: faker.random.word() }; + + safeStorage.cookie.set(key, value); + + (document.cookie as any) = undefined; + + safeStorage.cookie.get(key); + + expect(document.cookie).toEqual(undefined); + }); + + it('should enshure cookie.remove not been called when document.cookie is undefined', () => { + const safeStorage = makeSut(); + const key = faker.random.word(); + const value = { value: faker.random.word() }; + + safeStorage.cookie.set(key, value); + + (document.cookie as any) = undefined; + + safeStorage.cookie.remove(key); + + expect(document.cookie).toEqual(undefined); + }); + + it('should enshure cookie.remove been called', () => { + const safeStorage = makeSut(); + const key = faker.random.word(); + const value = { value: faker.random.word() }; + const spy = jest.spyOn(mockNotify, 'mockedFn'); + + safeStorage.cookie.set(key, value); + safeStorage.cookie.remove(key); + const result = safeStorage.cookie.get(key); + + expect(result).toEqual(''); + expect(spy).toHaveBeenCalledWith({ + value: undefined, + key, + type: 'remove:cookie', + }); + }); + + it('should enshure cookie.set been called with correct key and params', () => { + const safeStorage = makeSut(); + const key = faker.random.word(); + const value = faker.random.word(); + const spy = jest.spyOn(mockNotify, 'mockedFn'); + const date = faker.date.future(1); + const domain = faker.internet.domainName(); + const path = '/'; + const sameSite = faker.random.arrayElement([ + 'strict', + 'lax', + 'none', + ]); + + const params = { + expires: date, + path, + domain, + secure: true, + sameSite, + }; + + safeStorage.cookie.set(key, value, params); + + expect(document.cookie).toContain(key); + expect(document.cookie).toContain(`path=${path}`); + expect(document.cookie).toContain(`expires=${date.toUTCString()}`); + expect(document.cookie).toContain(`secure`); + expect(document.cookie).toContain(`domain=${domain}`); + expect(document.cookie).toContain(`samesite=${sameSite}`); + expect(spy).toHaveBeenCalledWith({ + value: undefined, + key, + type: 'set:cookie', + }); + }); + + it('should enshure cookie.set been called with expires in number', () => { + const safeStorage = makeSut({ + doNotParseValues: true, + }); + const key = faker.random.word(); + const value = faker.random.word(); + const spy = jest.spyOn(mockNotify, 'mockedFn'); + const date = faker.datatype.number(50); + const domain = faker.internet.domainName(); + + const params = { + expires: date, + domain, + }; + + safeStorage.cookie.set(key, value, params); + safeStorage.cookie.get(key); + + expect(document.cookie).toContain(key); + expect(spy).toHaveBeenCalledWith({ + value: undefined, + key, + type: 'set:cookie', + }); + }); + + it('should calls cookie with correct key', () => { + const safeStorage = makeSut(); + const key = faker.random.word(); + const value = faker.random.word(); + safeStorage.cookie.set(key, value); + + const spy = jest.spyOn(mockNotify, 'mockedFn'); + + safeStorage.cookie.get(key); + + expect(document.cookie).toContain(key); + expect(spy).toHaveBeenCalledWith({ + value: undefined, + key, + type: 'get:cookie', + }); + }); + + it('should cookie.get returns correct decrypted value', () => { + const safeStorage = makeSut(); + const key = faker.random.word(); + const value = { value: faker.random.word() }; + + safeStorage.cookie.set(key, value); + const storagedDecrypetdValue = safeStorage.cookie.get(key); + + expect(storagedDecrypetdValue).toEqual(value); + }); + + it('should cookie.get returns null', () => { + const safeStorage = makeSut(); + const key = faker.random.word(); + + const result = safeStorage.cookie.get(key); + + expect(result).toEqual(null); + }); + }); diff --git a/src/types.ts b/src/types.ts index 9b250d6..36df2f2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,10 +4,13 @@ type StorageType = 'localStorage' | 'sessionStorage'; type ChangeType = | 'set' + | 'set:cookie' | 'get' + | 'get:cookie' | 'getMultiple' | 'setMultiple' | 'remove' + | 'remove:cookie' | 'removeMultiple' | 'clear' | 'length' @@ -46,26 +49,66 @@ export interface GetFromPatternOptions extends RemoveFromPatternOptions { doNotDecrypt?: boolean; } +export interface SetItemWithTTLParams { + key: string; + value: any; + doNotEncrypt?: boolean; + /** + * in seconds + */ + ttl: number | Date; +} + +export interface CookieOptions { + expires?: number | Date; + path?: string; + domain?: string; + secure?: boolean; + sameSite?: 'strict' | 'lax' | 'none'; +} + +export interface RemoveCookieOptions { + path?: string; + domain?: string; +} +export interface CookieInterface { + set(key: string, value: any, options?: CookieOptions): void; + get(key: string): DataType | null; + remove(key: string, options?: RemoveCookieOptions): void; +} + export interface EncryptStorageInterface extends Storage { /** * `setItem` - Is the function to be set `safeItem` in `selected storage` * @param {string} key - Is the key of `data` in `selected storage`. * @param {any} value - Value to be `encrypted`, the same being a `string` or `object`. * @return {void} `void` - * @usage - * setItem('any_key', {key: 'value', another_key: 2}) - * setItem('any_key', 'any value') + * @example + * encryptStorage.setItem('any_key', {key: 'value', another_key: 2}); + * encryptStorage.setItem('any_key', 'any value'); */ setItem(key: string, value: any, doNotEncrypt?: boolean): void; + /** + * `setItemWithTTL` - Is the function to set an `safeItem` with `ttl` in `selected storage` + * @param {SetItemWithTTLParams} params - Is de `params` of function. + * @return {void} `void` + * @example + * encryptStorage.setItemWithTTL({ + * key: 'any_key', + * value: { key: 'value', another_key: 2 }, + * ttl: 60, // in seconds or Date + * }); + */ + setItemWithTTL?: (params: SetItemWithTTLParams) => void; /** * `setMultipeItems` - Is the function to be set `safeItem` in `selected storage` * @param {[string, any][]} param - . * @param {any} value - It's an `array` of `tuples` to be set at once... * @return {void} `void` - * @usage - * setItem(['any_key', {key: 'value', another_key: 2}]) - * setItem(['any_key', 'any value']) + * @example + * encryptStorage.setItem(['any_key', {key: 'value', another_key: 2}]) + * encryptStorage.setItem(['any_key', 'any value']) */ setMultipleItems(param: [string, any][], doNotEncrypt?: boolean): void; @@ -73,8 +116,8 @@ export interface EncryptStorageInterface extends Storage { * `hash` - Is the function to be `hash` value width SHA256 encryptation * @param {any} value - Value to be `hashed`, the same being a `string`. * @return {string} `hashed string` - * @usage - * hash('any_string') + * @example + * encryptStorage.hash('any_string') */ hash(value: string): string; @@ -82,8 +125,8 @@ export interface EncryptStorageInterface extends Storage { * `md5Hash` - Is the function to be `hash` value width MD5 encryptation * @param {any} value - Value to be `hashed`, the same being a `string`. * @return {string} `hashed string` - * @usage - * md5Hash('any_string') + * @example + * md5Hash('any_string') */ md5Hash(value: string): string; @@ -92,9 +135,9 @@ export interface EncryptStorageInterface extends Storage { * @param {string} key - Is the key of `data` in `selected storage`. * @return {string | any | undefined} - Returns a formatted value when the same is an object or string when not. * Returns `undefined` when value not exists. - * @usage - * getItem('any_key') -> `{key: 'value', another_key: 2}` - * getItem('any_key') -> `'any value'` + * @example + * encryptStorage.getItem('any_key') -> `{key: 'value', another_key: 2}` + * encryptStorage.getItem('any_key') -> `'any value'` */ getItem(key: string, doNotDecrypt?: boolean): string | any | undefined; @@ -103,9 +146,9 @@ export interface EncryptStorageInterface extends Storage { * @param {string[]} keys - Is the keys of `data` in `selected storage`. * @return {Record | undefined} - Returns a formatted value when the same is an object or string when not. * Returns `undefined` when value not exists. - * @usage - * getMultipleItems(['any_key']) -> `{any_key: {key: 'value', another_key: 2}}` - * getMultipleItems(['any_key']) -> `{any_key: 'any value'}` + * @example + * encryptStorage.getMultipleItems(['any_key']) -> `{any_key: {key: 'value', another_key: 2}}` + * encryptStorage.getMultipleItems(['any_key']) -> `{any_key: 'any value'}` */ getMultipleItems(keys: string[], doNotDecrypt?: boolean): Record; @@ -114,8 +157,8 @@ export interface EncryptStorageInterface extends Storage { * @param {string} key - Is the key of `data` in `selected storage`. * @return {void} * Returns `void`. - * @usage - * removeItem('any_key') + * @example + * encryptStorage.removeItem('any_key') */ removeItem(key: string): void; @@ -124,8 +167,8 @@ export interface EncryptStorageInterface extends Storage { * @param {string[]} keys - Is the keys of `data` in `selected storage`. * @return {void} * Returns `void`. - * @usage - * removeMultipleItems(['any_key_1'm 'any_key_2']) + * @example + * encryptStorage.removeMultipleItems(['any_key_1'm 'any_key_2']) */ removeMultipleItems(keys: string[]): void; @@ -134,10 +177,10 @@ export interface EncryptStorageInterface extends Storage { * @param {string} pattern - Is the pattern existent in keys of `selected storage`. * @return {any | Record | undefined} * Returns `void`. - * @usage - * // itemKey = '12345678:user' - * // another itemKey = '12345678:item' - * getItemFromPattern('12345678') -> {'12345678:user': 'value', '12345678:item': 'otherValue'} + * @example + * // itemKey = '12345678:user' + * // another itemKey = '12345678:item' + * encryptStorage.getItemFromPattern('12345678'); // -> {'12345678:user': 'value', '12345678:item': 'otherValue'} */ getItemFromPattern( pattern: string, @@ -149,10 +192,10 @@ export interface EncryptStorageInterface extends Storage { * @param {string} pattern - Is the pattern existent in keys of `selected storage`. * @return {void} * Returns `void`. - * @usage - * // itemKey = '12345678:user' - * // another itemKey = '12345678:item' - * removeItem('12345678') -> item removed from `selected storage` + * @example + * // itemKey = '12345678:user' + * // another itemKey = '12345678:item' + * encryptStorage.removeItem('12345678'); // -> item removed from `selected storage` */ removeItemFromPattern( pattern: string, @@ -161,12 +204,16 @@ export interface EncryptStorageInterface extends Storage { /** * `clear` - Clear all selected storage + * @example + * encryptStorage.clear(); */ clear(): void; /** * `key` - Return a `key` in selected storage index or `null` * @param {number} index - Index of `key` in `selected storage` + * @example + * encryptStorage.key(0); // -> 'any_key' */ key(index: number): string | null; @@ -176,8 +223,8 @@ export interface EncryptStorageInterface extends Storage { * @param {string} str - A `string` to be encrypted. * @return {string} result * Returns `string`. - * @usage - * encryptString('any_string') -> 'encrypted value' + * @example + * encryptStorage.encryptString('any_string'); // -> 'encrypted value' */ encryptString(str: string): string; @@ -186,9 +233,8 @@ export interface EncryptStorageInterface extends Storage { * `decryptString` - Is the function to be `decrypt` any string encrypted by `encryptString` and return decrypted value * @param {string} str - A `string` to be decrypted. * @return {string} result - * Returns `string`. - * @usage - * decryptString('any_string') -> 'decrypted value' + * @example + * encryptStorage.decryptString('any_string'); // -> 'decrypted value' */ decryptString(str: string): string; @@ -197,8 +243,8 @@ export interface EncryptStorageInterface extends Storage { * @param {string} value - A `value|object|array` to be encrypted. * @return {string} result * Returns `string`. - * @usage - * encryptString('any_string') -> 'encrypted value' + * @example + * encryptStorage.encryptString('any_string'); // -> 'encrypted value' */ encryptValue(value: any): string; @@ -207,8 +253,10 @@ export interface EncryptStorageInterface extends Storage { * @param {string} value - A `value|object|array` to be decrypted. * @return {T|any} result * Returns `string`. - * @usage - * decryptString('any_value') -> '{value: "decrypted value"}' + * @example + * encryptStorage.decryptString('any_value'); // -> '{value: "decrypted value"}' */ - decryptValue(key: string): T; + decryptValue(key: string): DataType; + + cookie: CookieInterface; } diff --git a/tsconfig.json b/tsconfig.json index 6893bc4..f0c9090 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,7 @@ "target": "es6", "module": "commonjs", "outDir": "./dist", - "lib": ["ES2015", "DOM"], + "lib": ["ES2020", "DOM"], "sourceMap": true, "declaration": true, "strict": true,