├── .npmignore ├── index.js ├── src ├── shared │ └── operators.js └── components │ ├── SessionEngine.js │ └── Session.js ├── types ├── index.d.ts └── components │ ├── SessionEngineOptions.d.ts │ ├── SessionEngine.d.ts │ └── Session.d.ts ├── LICENSE ├── package.json ├── README.md ├── .gitignore └── docs ├── Examples.md ├── SessionEngine.md └── Session.md /.npmignore: -------------------------------------------------------------------------------- 1 | docs/ -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const SessionEngine = require('./src/components/SessionEngine.js'); 2 | module.exports = SessionEngine; 3 | -------------------------------------------------------------------------------- /src/shared/operators.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Writes values from focus object onto base object. 3 | * 4 | * @param {Object} obj1 Base Object 5 | * @param {Object} obj2 Focus Object 6 | */ 7 | function wrap_object(original, target) { 8 | Object.keys(target).forEach((key) => { 9 | if (typeof target[key] == 'object') { 10 | if (Array.isArray(target[key])) return (original[key] = target[key]); // lgtm [js/prototype-pollution-utility] 11 | if (original[key] === null || typeof original[key] !== 'object') original[key] = {}; 12 | wrap_object(original[key], target[key]); 13 | } else { 14 | original[key] = target[key]; 15 | } 16 | }); 17 | } 18 | 19 | module.exports = { 20 | wrap_object, 21 | }; 22 | -------------------------------------------------------------------------------- /types/index.d.ts: -------------------------------------------------------------------------------- 1 | import SessionEngine from './components/SessionEngine'; 2 | 3 | import Session from 'hyper-express-session/types/components/Session'; 4 | import { Router as _Router } from 'hyper-express/types/components/router/Router'; 5 | import { MiddlewareHandler } from 'hyper-express/types/components/middleware/MiddlewareHandler'; 6 | 7 | declare module 'hyper-express' { 8 | interface Request { 9 | session: Session; 10 | } 11 | 12 | class Router extends _Router { 13 | use(sessionMiddleware: SessionEngine): void; 14 | use(pattern: string, sessionMiddleware: SessionEngine): void; 15 | use(router: Router): void; 16 | use(...routers: Router[]): void; 17 | use(...middlewares: MiddlewareHandler[]): void; 18 | use(pattern: string, router: Router): void; 19 | use(pattern: string, ...routers: Router[]): void; 20 | use(pattern: string, ...middlewares: MiddlewareHandler[]): void; 21 | } 22 | } 23 | 24 | export default SessionEngine; 25 | export { SessionEngine, Session }; 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Kartik 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hyper-express-session", 3 | "version": "1.1.5", 4 | "description": "High performance middleware that implements cookie based web sessions into the HyperExpress webserver.", 5 | "main": "index.js", 6 | "types": "./types/index.d.ts", 7 | "scripts": { 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/kartikk221/hyper-express-session.git" 13 | }, 14 | "keywords": [ 15 | "high", 16 | "performance", 17 | "cookie", 18 | "session", 19 | "flexible", 20 | "redis", 21 | "sql", 22 | "compatible", 23 | "node" 24 | ], 25 | "author": "kartikk221", 26 | "license": "MIT", 27 | "bugs": { 28 | "url": "https://github.com/kartikk221/hyper-express-session/issues" 29 | }, 30 | "homepage": "https://github.com/kartikk221/hyper-express-session#readme", 31 | "dependencies": { 32 | "@types/node": "^16.11.6", 33 | "uid-safe": "^2.1.5" 34 | }, 35 | "devDependencies": { 36 | "typescript": "^4.4.4" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /types/components/SessionEngineOptions.d.ts: -------------------------------------------------------------------------------- 1 | export interface SessionEngineCookieOptions { 2 | /** 3 | * Session cookie name 4 | */ 5 | name?: string; 6 | 7 | /** 8 | * Session cookie path 9 | */ 10 | path?: string; 11 | 12 | /** 13 | * Whether to add httpOnly flag to session cookie 14 | */ 15 | httpOnly?: boolean; 16 | 17 | /** 18 | * Whether to add secure flag to session cookie 19 | */ 20 | secure?: boolean; 21 | 22 | /** 23 | * Session cookie sameSite directive 24 | */ 25 | sameSite?: string | boolean; 26 | 27 | /** 28 | * Session cookie signature secret. Note: this is required! 29 | */ 30 | secret: string; 31 | } 32 | 33 | export interface SessionEngineOptions { 34 | /** 35 | * Session lifetime duration in milliseconds. Default: 1000 * 60 * 30 (30 Minutes) 36 | */ 37 | duration?: number; 38 | 39 | /** 40 | * Specifies whether all sessions should automatically be touched regardless of any changes. 41 | */ 42 | automatic_touch?: boolean; 43 | 44 | /** 45 | * Session cookie options 46 | */ 47 | cookie: SessionEngineCookieOptions; 48 | } 49 | -------------------------------------------------------------------------------- /types/components/SessionEngine.d.ts: -------------------------------------------------------------------------------- 1 | import Session from './Session'; 2 | import { SessionData } from './Session'; 3 | import { SessionEngineOptions } from './SessionEngineOptions'; 4 | 5 | type EngineActionHandler = (session: Session) => void | Promise; 6 | type EngineReactionHandler = (session: Session) => SessionData | Promise; 7 | 8 | interface EngineMethods { 9 | read?: EngineReactionHandler; 10 | touch?: EngineActionHandler; 11 | write?: EngineActionHandler; 12 | destroy?: EngineActionHandler; 13 | id?: () => string; 14 | cleanup?: () => void; 15 | } 16 | 17 | export default class SessionEngine { 18 | /** 19 | * 20 | * @param options SessionEngine Options 21 | */ 22 | constructor(options: SessionEngineOptions); 23 | 24 | /* SessionEngine Methods */ 25 | 26 | /** 27 | * This method is used to specify a handler for session engine operations. 28 | */ 29 | use(type: 'touch' | 'write' | 'destroy', handler: EngineActionHandler): SessionEngine; 30 | use(type: 'read', handler: EngineReactionHandler): SessionEngine; 31 | use(type: 'id', handler: () => string): SessionEngine; 32 | use(type: 'cleanup', handler: () => void): SessionEngine; 33 | 34 | /** 35 | * Triggers 'cleanup' operation based on the assigned "cleanup" handler. 36 | */ 37 | cleanup(): void; 38 | 39 | /* SessionEngine Getters */ 40 | 41 | /** 42 | * SessionEngine constructor options. 43 | * @returns {SessionEngineOptions} 44 | */ 45 | get options(): SessionEngineOptions; 46 | 47 | /** 48 | * SessionEngine assigned operation method handlers. 49 | */ 50 | get methods(): EngineMethods; 51 | 52 | /** 53 | * SessionEngine middleware function to be passed into HyperExpress.use() method. 54 | * @returns {Function} 55 | */ 56 | get middleware(): Function; 57 | } 58 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HyperExpress.Session: High Performance Cookie Sessions Middleware 2 | #### Built for [`HyperExpress`](https://github.com/kartikk221/hyper-express) 3 | 4 |
5 | 6 | [![NPM version](https://img.shields.io/npm/v/hyper-express-session.svg?style=flat)](https://www.npmjs.com/package/hyper-express-session) 7 | [![NPM downloads](https://img.shields.io/npm/dm/hyper-express-session.svg?style=flat)](https://www.npmjs.com/package/hyper-express-session) 8 | [![Language grade: JavaScript](https://img.shields.io/lgtm/grade/javascript/g/kartikk221/hyper-express-session.svg?logo=lgtm&logoWidth=18)](https://lgtm.com/projects/g/kartikk221/hyper-express-session/context:javascript) 9 | [![GitHub issues](https://img.shields.io/github/issues/kartikk221/hyper-express-session)](https://github.com/kartikk221/hyper-express-session/issues) 10 | [![GitHub stars](https://img.shields.io/github/stars/kartikk221/hyper-express-session)](https://github.com/kartikk221/hyper-express-session/stargazers) 11 | [![GitHub license](https://img.shields.io/github/license/kartikk221/hyper-express-session)](https://github.com/kartikk221/hyper-express-session/blob/master/LICENSE) 12 | 13 |
14 | 15 | ## Motivation 16 | HyperExpress.Session aims to provide a simple middleware that implements a session engine with high flexibility while being performant. This middleware is unopinionated and can be used with any storage mechanism of your choice. This middleware ships with TypeScript types out of the box, so both vanilla Javascript and TypeScript projects are welcome. 17 | 18 | ## Installation 19 | HyperExpress and HyperExpressWS can both be installed using node package manager (`npm`) 20 | ``` 21 | npm i hyper-express-session 22 | ``` 23 | 24 | ## Documentation 25 | - See [`> [Examples & Snippets]`](./docs/Examples.md) for small and **easy-to-use snippets** with HyperExpress.Session. 26 | - See [`> [SessionEngine]`](./docs/SessionEngine.md) for creating a session engine and working with the **SessionEngine** component. 27 | - See [`> [Session]`](./docs/Session.md) for working with the **Session** component to manipulate request sessions. 28 | 29 | ## License 30 | [MIT](./LICENSE) 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | -------------------------------------------------------------------------------- /docs/Examples.md: -------------------------------------------------------------------------------- 1 | # Examples & Snippets 2 | Below are various examples and snippets that make use of `SessionEngine` and `Session` components. 3 | 4 | #### Example: Initializing & Binding A Session Engine With Redis Store Implementation 5 | ```javascript 6 | const SessionEngine = require('hyper-express-session'); 7 | const TestEngine = new SessionEngine({ 8 | duration: 1000 * 60 * 45, // Default duration is 45 Minutes 9 | cookie: { 10 | name: 'example_sess', 11 | path: '/', 12 | httpOnly: true, 13 | secure: true, 14 | sameSite: 'strict', 15 | secret: 'SomeSuperSecretForSigningCookies' 16 | } 17 | }); 18 | 19 | // Bind session engine handlers for storing sessions in Redis store 20 | TestEngine.use('read', async (session) => { 21 | const data = await redis.get('session:' + session.id); 22 | if(typeof data == 'string') return JSON.parse(data); 23 | }); 24 | 25 | TestEngine.use('touch', async (session) => { 26 | return await redis.pexpireat('session:' + session.id, session.expires_at); 27 | }); 28 | 29 | TestEngine.use('write', async (session) => { 30 | const key = 'session:' + session.id; 31 | 32 | // We use redis pipeline to perform two operations in one go 33 | return await redis.pipeline() 34 | .set(key, JSON.stringify(session.get())) 35 | .pexpireat(key, session.expires_at) 36 | .exec(); 37 | }); 38 | 39 | TestEngine.use('destroy', async (session) => { 40 | return await redis.del('session:' + session.id); 41 | }); 42 | 43 | // Use middleware from TestEngine in a HyperExpress webserver instance 44 | webserver.use(TestEngine); 45 | ``` 46 | 47 | #### Example: Initiating and storing visits in a session 48 | ```js 49 | // Assume a SessionEngine instance has already been setup for this route 50 | webserver.get('/dashboard/news', async (request, response) => { 51 | // Initiate a session asynchronously 52 | await request.session.start(); 53 | 54 | // Read session for visits property and iterate 55 | let visits = request.session.get('visits'); 56 | if (visits == undefined){ 57 | request.session.set('visits', 1); // Initiate visits property in session 58 | } else { 59 | request.session.set('visits', visits + 1); // Iterate visists by 1 60 | } 61 | 62 | return response.html(news_html); 63 | }); 64 | ``` -------------------------------------------------------------------------------- /docs/SessionEngine.md: -------------------------------------------------------------------------------- 1 | # SessionEngine 2 | Below is a breakdown of the `SessionEngine` object class generated while creating a new session engine instance. A single session engine can be shared across multiple `HyperExpress.Server` instances. 3 | 4 | #### SessionEngine Constructor Options 5 | * `duration`[`Number`]: Specifies the lifetime of sessions in **milliseconds**. 6 | * **Default:** `1000 * 60 * 30` (30 Minutes) 7 | * `automatic_touch`[`Boolean`]: Specifies whether active sessions should be `touched` regardless of data changes upon each request. 8 | * **Default:** `true` 9 | * `cookie`[`Object`]: Specifies session cookie options. 10 | * `name`[`String`]: Cookie Name 11 | * `domain`[`String`]: Cookie Domain 12 | * `path`[`String`]: Cookie Path 13 | * `secure`[`Boolean`]: Adds Secure Flag 14 | * `httpOnly`[`Boolean`]: Adds httpOnly Flag 15 | * `sameSite`[`Boolean`, `'none'`, `'lax'`, `'strict'`]: Cookie Same-Site Preference 16 | * `secret`[`String`]: Specifies secret value used to sign/authenticate session cookies. 17 | * **Note!** a strong and unique string is required for `cookie.secret`. 18 | 19 | ### SessionEngine Instance Properties 20 | | Property | Type | Description | 21 | | :-------- | :------- | :------------------------- | 22 | | `middleware` | `Function` | Middleware handler to be used with `HyperExpress.use()`. | 23 | 24 | #### SessionEngine Methods 25 | * `use(String: type, Function: handler)`: Binds a handler for specified operation `type`. 26 | * **Note** you must use your own storage implementation in combination with available operations below. 27 | * **Supported Operations:** 28 | * [`read`]: Must read and return session data as an `Object` from your storage. 29 | * **Parameters**: `(Session: session) => {}`. 30 | * **Expects** A `Promise` which then resolves to an `Object` or `undefined` type. 31 | * **Required** 32 | * [`touch`]: Must update session expiry timestamp in your storage. 33 | * **Parameters**: `(Session: session) => {}`. 34 | * **Expects** A `Promise` which is then resolved to `Any` type. 35 | * **Required** 36 | * [`write`]: Must write session data and update expiry timestamp to your storage. 37 | * **Parameters**: `(Session: session) => {}`. 38 | * You can use `session.stored` to determine if you need to `INSERT` or `UPDATE` for SQL based implementations. 39 | * **Expects** A `Promise` which then resolves to `Any` type. 40 | * **Required** 41 | * [`destroy`]: Must destroy session from your storage. 42 | * **Parameters**: `(Session: session) => {}`. 43 | * **Expects** A `Promise` which then resolves to `Any` type. 44 | * **Required** 45 | * [`id`]: Must return a promise that generates and resolves a cryptographically random id. 46 | * **Parameters**: `() => {}`. 47 | * **Expects** A `Promise` which then resolves to `String` type. 48 | * **Optional** 49 | * [`cleanup`]: Must clean up expired sessions from your storage. 50 | * **Parameters**: `() => {}`. 51 | * **Expects** A `Promise` which then resolves to `Any` type. 52 | * **Optional** 53 | * See [`> [Session]`](./Session.md) for working with the `session` parameter. 54 | * `cleanup()`: Triggers `cleanup` operation handler to delete expired sessions from storage. -------------------------------------------------------------------------------- /docs/Session.md: -------------------------------------------------------------------------------- 1 | ## Session 2 | Below is a breakdown of the `session` object made available through the `request.session` property in route/middleware handler(s). 3 | 4 | #### Session Properties 5 | | Property | Type | Description | 6 | | :-------- | :------- | :------------------------- | 7 | | `id` | `Number` | Raw session id for current request. | 8 | | `signed_id` | `Number` | Signed session id for current request. | 9 | | `ready` | `Boolean` | Specifies whether session has been started. | 10 | | `stored` | `Boolean` | Specifies whether session is already stored in database. | 11 | | `duration` | `Number` | Duration in **milliseconds** of current session. | 12 | | `expires_at` | `Number` | Expiry timestamp in **milliseconds** of current session. | 13 | 14 | #### Session Methods 15 | Methods that return `Session` are chainable to allow for cleaner code. 16 | 17 | * `generate_id()`: Asynchronously generates and returns a new session id from `'id'` session engine event. 18 | * **Returns** `Promise`->`String` 19 | * `set_id(String: session_id)`: Overwrites/Sets session id for current request session. 20 | * **Returns** `Session` 21 | * **Note** this method is not recommended in conjunction with user input as it performs no verification. 22 | * `set_signed_id(String: signed_id, String: secret)`: Overwrites/Sets session id for current request session. 23 | * **Returns** `Session` 24 | * **Note** this method is **recommended** over `set_id` as it will first unsign/verify the provided signed id and then update the state of current session. 25 | * `secret` is **optional** as this method uses the underlying `SessionEngine.cookie.secret` by default. 26 | * `set_duration(Number: duration)`: Sets a custom session lifetime duration for current session. 27 | * **Returns** `Session` 28 | * **Note** this method stores the custom duration value as a part of the session data in a prefix called `__cust_dur`. 29 | * `start()`: Starts session on incoming request and loads session data from storage source. 30 | * **Returns** `Promise`. 31 | * `roll()`: Rolls current session's id by migrating current session data to a new session id. 32 | * **Returns** `Promise` -> `Boolean` 33 | * `touch()`: Updates current session's expiry timestamp in storage. 34 | * **Returns** `Promise` 35 | * **Note** This method is automatically called at the end of each request when `automatic_touch` is enabled in `SessionEngine` options. 36 | * `destroy()`: Destroys current session from storage and set's cookie header to delete session cookie. 37 | * **Returns** `Promise` 38 | * `set(String: name, Any: value)`: Sets session data value. You set multiple values by passing an `Object` parameter. 39 | * **Returns** `Session` 40 | * **Single Example:** `session.set('id', 'some_id')` 41 | * **Multiple Example:** `session.set({ id: 'some_id', email: 'some_email' })` 42 | * `reset(Object: data)`: Replaces existing session data values with values from the provided `data` object. 43 | * **Returns** `Session` 44 | * `get(String: name)`: Returns session data value for specified name. You may **omit** `name` to get **all** session data values. 45 | * **Returns** `Any`, `Object`, `undefined` 46 | * **Get One Example**: `session.get('email');` will return the session data value for `email` or `undefined` if it is not set. 47 | * **Get All Example**: `session.get()` will return all session data values in an `Object`. 48 | * `delete(String: name)`: Deletes session data value at specified name. You may **omit** `name` to delete **all** session data values. 49 | * **Returns** `Session` -------------------------------------------------------------------------------- /types/components/Session.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This interface allows you to declare additional properties on your session object using [declaration merging](https://www.typescriptlang.org/docs/handbook/declaration-merging.html). 3 | * 4 | * @example 5 | * import 'hyper-express-session/types/components/Session'; 6 | * 7 | * declare module 'hyper-express-session/types/components/Session' { 8 | * interface SessionData { 9 | * views: number; 10 | * } 11 | * } 12 | * 13 | */ 14 | export interface SessionData { 15 | /** 16 | * This property is used to internally store the custom session duration in milliseconds. 17 | * DO NOT DIRECTLY MODIFY THIS PROPERTY. 18 | */ 19 | __cust_dur?: number; 20 | } 21 | 22 | export default class Session { 23 | /* Session Methods */ 24 | 25 | /** 26 | * This method asynchronously generates a strong cryptographically random session id. 27 | * 28 | * @returns {Promise} 29 | */ 30 | generate_id(): Promise; 31 | 32 | /** 33 | * This method sets the current session's id to provided session_id. 34 | * Note! This method does not perform any verification on provided session_id 35 | * and thus is not recommended to be used with any user a provided id. 36 | */ 37 | set_id(session_id: string): Session; 38 | 39 | /** 40 | * This method sets the current session's id to provided signed session id. 41 | * Note! This method is recommended over .set_id() as this method will attempt to 42 | * unsign the the provided id and thus verifies user input. 43 | */ 44 | set_signed_id(signed_id: string, secret?: string): boolean; 45 | 46 | /** 47 | * This method is used to change the duration of current session to a custom value in milliseconds. 48 | */ 49 | set_duration(duration: number): Session; 50 | 51 | /** 52 | * This method is used to start a session for incoming request. 53 | * Note! This method is asynchronous as it performs the 'read' operation to read session data. 54 | */ 55 | start(): Promise; 56 | 57 | /** 58 | * Rolls current session's id to a new session id. 59 | * Note! This operation performs 2 underlying operations as it first 60 | * deletes old session and then persists session data under new session id. 61 | */ 62 | roll(): Promise; 63 | 64 | /** 65 | * This method performs the 'touch' operation updating session's expiry in storage. 66 | */ 67 | touch(): Promise; 68 | 69 | /** 70 | * This method is used to destroy the current session. 71 | * Note! This method is asynchronous as it instantly triggers 72 | * the 'destroy' operation causing session to be deleted from storage mechanism. 73 | */ 74 | destroy(): Promise; 75 | 76 | /** 77 | * This method is used to set one or multiple session data values. 78 | * You may provide a name and value argument to update a single value. 79 | * You may provide an Object of keys/values to update multiple values in one operation. 80 | */ 81 | set(name: T, value: SessionData[T]): Session; 82 | set(sessionData: SessionData): Session; 83 | 84 | /** 85 | * This method replaces current session data values with provided data values object. 86 | */ 87 | reset(data: SessionData): Session; 88 | 89 | /** 90 | * This method is used to retrieve data values from current session. 91 | * You may retrieve all session data values by providing no name parameter. 92 | */ 93 | get(name: T): SessionData[T]; 94 | get(): SessionData; 95 | 96 | /** 97 | * This method is used to delete data values from current session. 98 | * You may delete all session data values by providing no name. 99 | */ 100 | delete(name: T): Session; 101 | delete(): Session; 102 | 103 | /* Session Getters */ 104 | 105 | /** 106 | * Parses and returns session id from current request based on session cookie. 107 | */ 108 | get id(): string | void; 109 | 110 | /** 111 | * This method is an alias of .id() except it returns the raw signed id. 112 | */ 113 | get signed_id(): string | void; 114 | 115 | /** 116 | * Returns whether session is ready and its data has been retrieved. 117 | */ 118 | get ready(): boolean; 119 | 120 | /** 121 | * Returns whether session has already been stored in database or not. 122 | * This is helpful for choosing between INSERT/UPDATE operations for SQL based implementations. 123 | */ 124 | get stored(): boolean; 125 | 126 | /** 127 | * Returns the current session's lifetime duration in milliseconds. 128 | */ 129 | get duration(): number; 130 | 131 | /** 132 | * Returns the expiry UNIX timestamp in milliseconds of current session. 133 | */ 134 | get expires_at(): number; 135 | } 136 | -------------------------------------------------------------------------------- /src/components/SessionEngine.js: -------------------------------------------------------------------------------- 1 | const Session = require('./Session.js'); 2 | const UidSafe = require('uid-safe'); 3 | const { wrap_object } = require('../shared/operators.js'); 4 | 5 | /** 6 | * @typedef {Record} SessionData 7 | * @typedef {function(Session):void|Promise} EngineActionHandler 8 | * @typedef {function(Session):SessionData|Promise} EngineReactionHandler 9 | */ 10 | 11 | class SessionEngine { 12 | #middleware; 13 | #options = { 14 | automatic_touch: true, 15 | duration: 1000 * 60 * 30, 16 | cookie: { 17 | name: 'default_sess', 18 | path: '/', 19 | httpOnly: true, 20 | secure: true, 21 | sameSite: 'none', 22 | secret: null, 23 | }, 24 | }; 25 | 26 | /** 27 | * @param {Object} options SessionEngine Options 28 | * @param {Number} options.duration Session lifetime duration in milliseconds. Default: 1000 * 60 * 30 (30 Minutes) 29 | * @param {Boolean} options.automatic_touch Specifies whether all sessions should automatically be touched regardless of any changes. 30 | * @param {Object} options.cookie Session cookie options 31 | * @param {String} options.cookie.name Session cookie name 32 | * @param {String} options.cookie.path Session cookie path 33 | * @param {Boolean} options.cookie.httpOnly Whether to add httpOnly flag to session cookie 34 | * @param {Boolean} options.cookie.secure Whether to add secure flag to session cookie 35 | * @param {String|Boolean} options.cookie.sameSite Session cookie sameSite directive 36 | * @param {String} options.cookie.secret Session cookie signature secret. Note! this is required! 37 | */ 38 | constructor(options) { 39 | // Ensure options is a valid object 40 | if (options == null || typeof options !== 'object') 41 | throw new Error('new SessionEngine(options) -> options must be an object.'); 42 | 43 | // Wrap local options object from provided options 44 | wrap_object(this.#options, options); 45 | 46 | // Ensure the session duration is a valid number 47 | const duration = this.#options.duration; 48 | if (typeof duration !== 'number' || duration < 1) 49 | throw new Error('new SessionEngine(options.duration) -> duration must be a valid number in milliseconds.'); 50 | 51 | // Ensure user has specified a secret as it is required 52 | const secret = this.#options.cookie.secret; 53 | if (typeof secret !== 'string' || secret.length < 10) 54 | throw new Error( 55 | 'new SessionEngine(options.cookie.secret) -> secret must be a unique and strong random string.' 56 | ); 57 | 58 | // Create and store a middleware function that attaches Session to each request 59 | const session_engine = this; 60 | this.#middleware = (request, response, next) => { 61 | // Bind Session component to each request on the 'session' property 62 | request.session = new Session(request, response, session_engine); 63 | next(); 64 | }; 65 | } 66 | 67 | /** 68 | * This method throws a session engine unhandled operation error. 69 | * 70 | * @private 71 | * @param {String} type 72 | */ 73 | _not_setup_method(type) { 74 | throw new Error( 75 | `SessionEngine '${type}' operation is not being handled. Please use SessionEngine.use('${action}', some_handler) to handle this session engine operation.` 76 | ); 77 | } 78 | 79 | #methods = { 80 | id: () => UidSafe(24), // generates 32 length secure id 81 | touch: () => this._not_setup_method('touch'), 82 | read: () => this._not_setup_method('read'), 83 | write: () => this._not_setup_method('write'), 84 | destroy: () => this._not_setup_method('destroy'), 85 | cleanup: () => this._not_setup_method('cleanup'), 86 | }; 87 | 88 | /** 89 | * This method is used to specify a handler for session engine operations. 90 | * 91 | * @param {('id'|'touch'|'read'|'write'|'destroy'|'cleanup')} type [id, touch, read, write, destroy] 92 | * @param {EngineActionHandler|EngineReactionHandler} handler 93 | * @returns {SessionEngine} SessionEngine (Chainable) 94 | */ 95 | use(type, handler) { 96 | // Ensure type is valid string that is supported 97 | if (typeof type !== 'string' || this.#methods[type] == undefined) 98 | throw new Error('SessionEngine.use(type, handler) -> type must be a string that is a supported operation.'); 99 | 100 | // Ensure handler is an executable function 101 | if (typeof handler !== 'function') 102 | throw new Error('SessionEngine.use(type, handler) -> handler must be a Function.'); 103 | 104 | // Store handler and return self for chaining 105 | this.#methods[type] = handler; 106 | return this; 107 | } 108 | 109 | /** 110 | * Triggers 'cleanup' operation based on the assigned "cleanup" handler. 111 | */ 112 | cleanup() { 113 | return this.#methods.cleanup(); 114 | } 115 | 116 | /* SessionEngine Getters */ 117 | 118 | /** 119 | * SessionEngine constructor options. 120 | */ 121 | get options() { 122 | return this.#options; 123 | } 124 | 125 | /** 126 | * SessionEngine assigned operation method handlers. 127 | */ 128 | get methods() { 129 | return this.#methods; 130 | } 131 | 132 | /** 133 | * SessionEngine middleware function to be passed into HyperExpress.use() method. 134 | * @returns {Function} 135 | */ 136 | get middleware() { 137 | return this.#middleware; 138 | } 139 | } 140 | 141 | module.exports = SessionEngine; 142 | -------------------------------------------------------------------------------- /src/components/Session.js: -------------------------------------------------------------------------------- 1 | class Session { 2 | // Session Core Data 3 | #id; 4 | #request; 5 | #response; 6 | #signed_id; 7 | #session_data = {}; 8 | #session_engine; 9 | #prefixes = { 10 | duration: '__cust_dur', 11 | }; 12 | 13 | // Session State Booleans 14 | #parsed_id = false; 15 | #ready = false; 16 | #from_database = false; 17 | #persist = false; 18 | #destroyed = false; 19 | 20 | constructor(request, response, session_engine) { 21 | // Store request, response and session engine object to be used by instance throughout operation 22 | this.#request = request; 23 | this.#response = response; 24 | this.#session_engine = session_engine; 25 | 26 | // Bind a hook on 'prepare' event for performing closure at the end of request 27 | response.on('prepare', () => { 28 | // Ensure the session was started / ready to then perform closure 29 | // This is because we do not want to perform closure on a session that was not started for various reasons (e.g. no session cookie sent with request) or (e.g. custom session duration not loaded from database) 30 | if (this.#ready) this._perform_closure(); 31 | }); 32 | } 33 | 34 | /** 35 | * This method asynchronously generates a strong cryptographically random session id. 36 | * 37 | * @returns {Promise} 38 | */ 39 | async generate_id() { 40 | return await this.#session_engine.methods.id(); 41 | } 42 | 43 | /** 44 | * This method sets the current session's id to provided session_id. 45 | * Note! This method does not perform any verification on provided session_id 46 | * and thus is not recommended to be used with any user a provided id. 47 | * 48 | * @param {String} id 49 | * @returns {Session} Session (chainable) 50 | */ 51 | set_id(session_id) { 52 | if (typeof session_id !== 'string') throw new Error('set_id(id) -> id must be a string'); 53 | this.#id = session_id; 54 | this.#parsed_id = true; 55 | return this; 56 | } 57 | 58 | /** 59 | * This method sets the current session's id to provided signed session id. 60 | * Note! This method is recommended over .set_id() as this method will attempt to 61 | * unsign the the provided id and thus verifies user input. 62 | * 63 | * @param {String} signed_id Signed Session ID 64 | * @param {String=} secret Optional (Utilizes SessionEngine.options.cookie.secret by default) 65 | * @returns {Boolean} 66 | */ 67 | set_signed_id(signed_id, secret) { 68 | // Attempt to unsign provided id and secret with fallback to Session Engine secret 69 | const final_secret = secret || this.#session_engine.options.cookie.secret; 70 | const unsigned_id = this.#request.unsign(signed_id, final_secret); 71 | 72 | // Return false if unsigning process fails, likely means bad signature 73 | if (unsigned_id === false) return false; 74 | 75 | // Set provided unsigned/signed_id to Session state 76 | this.#id = unsigned_id; 77 | this.#signed_id = signed_id; 78 | this.#parsed_id = true; 79 | return true; 80 | } 81 | 82 | /** 83 | * This method is used to change the duration of current session to a custom value in milliseconds. 84 | * 85 | * @param {Number} duration In Milliseconds 86 | * @returns {Session} Session (Chainable) 87 | */ 88 | set_duration(duration) { 89 | // Ensure provided duration is a valid number in milliseconds 90 | if (typeof duration !== 'number' || duration < 1) 91 | throw new Error( 92 | 'SessionEngine: Session.set_duration(duration) -> duration must be a valid number in milliseconds.' 93 | ); 94 | 95 | // Store custom duration as a part of the session data 96 | return this.set(this.#prefixes.duration, duration); 97 | } 98 | 99 | /** 100 | * This method is used to start a session for incoming request. 101 | * Note! This method is asynchronous as it performs the 'read' operation to read session data. 102 | * 103 | * @returns {Promise} 104 | */ 105 | async start() { 106 | // Return if session has already started 107 | if (this.#ready) return; 108 | 109 | // Retrieve session id to determine if a session cookie was sent with request 110 | const session_id = this.id; 111 | if (typeof session_id !== 'string' || session_id.length == 0) { 112 | // Generate a new session id since no session cookie was sent with request 113 | this.#id = await this.generate_id(); 114 | this.#parsed_id = true; 115 | this.#ready = true; 116 | return; // Return since this is a brand new session and we do not need to perform a 'read' 117 | } 118 | 119 | // Perform 'read' operation to retrieve any associated data for this session 120 | const session_data = await this.#session_engine.methods.read(this); 121 | if (session_data && typeof session_data == 'object') { 122 | this.#from_database = true; 123 | this.#session_data = session_data; 124 | } else { 125 | // This will be useful to user for choosing between INSERT or UPDATE operation during 'write' operation 126 | this.#from_database = false; 127 | } 128 | 129 | // Mark session as ready so rest of the methods can be used 130 | this.#ready = true; 131 | } 132 | 133 | /** 134 | * Throws an Error alerting user for a session not being started for ready only methods. 135 | * @private 136 | */ 137 | _session_not_started(method) { 138 | throw new Error( 139 | 'SessionEngine: Session was not started. Please call Request.session.start() before calling Request.session.' + 140 | method + 141 | '()' 142 | ); 143 | } 144 | 145 | /** 146 | * Rolls current session's id to a new session id. 147 | * Note! This operation performs 2 underlying operations as it first 148 | * deletes old session and then persists session data under new session id. 149 | * 150 | * @returns {Promise} 151 | */ 152 | async roll() { 153 | // Throw not started error if session was not started/ready 154 | if (!this.#ready) return this._session_not_started('roll'); 155 | 156 | // Destroy old session if it is from database 157 | if (this.#from_database) await this.destroy(); 158 | 159 | // Generate a new session id for current session 160 | this.#id = await this.generate_id(); 161 | this.#signed_id = null; // Since we generated a new session id, we will need to re-sign 162 | this.#parsed_id = true; 163 | this.#destroyed = false; // This is to override the destroy() method 164 | this.#from_database = false; 165 | this.#persist = true; // This is so the new session persists at the end of this request 166 | return true; 167 | } 168 | 169 | /** 170 | * This method performs the 'touch' operation updating session's expiry in storage. 171 | * 172 | * @returns {Promise} 173 | */ 174 | touch() { 175 | // Return if no session cookie was sent with request 176 | if (typeof this.id !== 'string') return; 177 | 178 | // Invocate touch operation from session engine 179 | return this.#session_engine.methods.touch(this); 180 | } 181 | 182 | /** 183 | * This method is used to destroy the current session. 184 | * Note! This method is asynchronous as it instantly triggers 185 | * the 'destroy' operation causing session to be deleted from storage mechanism. 186 | * 187 | * @returns {Promise} 188 | */ 189 | async destroy() { 190 | // Return if session has already been destroyed 191 | if (this.#destroyed) return; 192 | 193 | // Return if no session cookie was sent with request 194 | if (typeof this.id !== 'string') return; 195 | 196 | // Make sure session has been started before we attempt to destroy it 197 | if (!this.#ready) await this.start(); 198 | 199 | // Perform 'destroy' operation to delete session data from storage 200 | if (this.#from_database) await this.#session_engine.methods.destroy(this); 201 | 202 | // Empty local session data and mark instance to be destroyed 203 | this.#session_data = {}; 204 | this.#destroyed = true; 205 | } 206 | 207 | /** 208 | * This method is used to set one or multiple session data values. 209 | * You may provide a name and value argument to update a single value. 210 | * You may provide an Object of keys/values to update multiple values in one operation. 211 | * 212 | * @param {String|Object} name 213 | * @param {Any} value 214 | * @returns {Session} Session (Chainable) 215 | */ 216 | set(name, value) { 217 | // Ensure session has been started before trying to set values 218 | if (!this.#ready) return this._session_not_started('set'); 219 | 220 | // Update local session data based on provided format 221 | if (typeof name == 'string') { 222 | this.#session_data[name] = value; 223 | } else { 224 | Object.keys(name).forEach((key) => (this.#session_data[key] = name[key])); 225 | } 226 | 227 | // Mark session instance to be persisted 228 | this.#persist = true; 229 | return this; 230 | } 231 | 232 | /** 233 | * This method replaces current session data values with provided data values object. 234 | * 235 | * @param {Object} data 236 | * @returns {Session} Session (Chainable) 237 | */ 238 | reset(data = {}) { 239 | // Ensure data is an object 240 | if (data === null || typeof data !== 'object') 241 | throw new Error('SessionEngine: Session.reset(data) -> data must be an Object.'); 242 | 243 | // Ensure session has been started before trying to set values 244 | if (!this.#ready) return this._session_not_started('set'); 245 | 246 | // Overwrite all session data and mark instance to be persisted 247 | this.#session_data = data; 248 | this.#persist = true; 249 | return this; 250 | } 251 | 252 | /** 253 | * This method is used to retrieve data values from current session. 254 | * You may retrieve all session data values by providing no name. 255 | * 256 | * @param {String} name Optional 257 | * @returns {Any|Object|undefined} 258 | */ 259 | get(name) { 260 | // Ensure session is started before trying to read values 261 | if (!this.#ready) return this._session_not_started('get'); 262 | 263 | // Return all session data if no name is provided 264 | if (name == undefined) return this.#session_data; 265 | 266 | // Return specific session data value if name is provided 267 | return this.#session_data[name]; 268 | } 269 | 270 | /** 271 | * This method is used to delete data values from current session. 272 | * You may delete all session data values by providing no name. 273 | * 274 | * @param {String} name 275 | * @returns {Session} Session (Chainable) 276 | */ 277 | delete(name) { 278 | // Ensure session is started before trying to delete values 279 | if (!this.#ready) return this._session_not_started('delete'); 280 | 281 | // Delete single or all values depending on name parameter 282 | if (name) { 283 | delete this.#session_data[name]; 284 | } else { 285 | this.#session_data = {}; 286 | } 287 | 288 | // Mark instance to be persisted 289 | this.#persist = true; 290 | return this; 291 | } 292 | 293 | /** 294 | * Performs session closure by persisting any data and writing session cookie header. 295 | * @private 296 | */ 297 | async _perform_closure() { 298 | // Set set-cookie header depending on session state 299 | const cookie = this.#session_engine.options.cookie; 300 | if (this.#destroyed) { 301 | // Delete session cookie as session was destroyed 302 | this.#response.cookie(cookie.name, null); 303 | } else if (typeof this.#signed_id == 'string') { 304 | // Write session cookie without signing to save on CPU operations 305 | this.#response.cookie(cookie.name, this.#signed_id, this.duration, cookie, false); 306 | } else if (typeof this.#id == 'string') { 307 | // Write session cookie normally as we do not have a cached signed id 308 | this.#response.cookie(cookie.name, this.#id, this.duration, cookie); 309 | } 310 | 311 | // Return if session has been destroyed as we have nothing to persist 312 | if (this.#destroyed) return; 313 | 314 | try { 315 | // Execute 'touch' operation if automatic_touch is enabled 316 | const automatic_touch = this.#session_engine.options.automatic_touch; 317 | if (this.#persist) { 318 | // Execute 'write' operation to persist changes 319 | await this.#session_engine.methods.write(this); 320 | } else if (this.#from_database && automatic_touch === true) { 321 | await this.touch(); 322 | } 323 | } catch (error) { 324 | // Pipe error to the global error handler 325 | this.#response.throw(error); 326 | } 327 | } 328 | 329 | /* Session Getters */ 330 | 331 | /** 332 | * Parses and returns session id from current request based on session cookie. 333 | * 334 | * @returns {String|undefined} 335 | */ 336 | get id() { 337 | // Return from cache if id has already been parsed once 338 | if (this.#parsed_id) return this.#id; 339 | 340 | // Attempt to read session cookie from request headers 341 | const cookie_options = this.#session_engine.options.cookie; 342 | const signed_cookie_id = this.#request.cookies[cookie_options.name]; 343 | if (signed_cookie_id) { 344 | // Unsign the raw cookie header value 345 | const unsigned_value = this.#request.unsign(signed_cookie_id, cookie_options.secret); 346 | 347 | // Store raw id and signed id locally for faster access in future operations 348 | if (unsigned_value !== false) { 349 | this.#id = unsigned_value; 350 | this.#signed_id = signed_cookie_id; 351 | } 352 | } 353 | 354 | // Mark session id as parsed for faster lookups 355 | this.#parsed_id = true; 356 | return this.#id; 357 | } 358 | 359 | /** 360 | * This method is an alias of .id() except it returns the raw signed id. 361 | * 362 | * @returns {String|undefined} 363 | */ 364 | get signed_id() { 365 | // Check cache for faster lookup 366 | if (this.#signed_id) return this.#signed_id; 367 | 368 | // Retrieve parsed session id and sign it with session engine specified signature 369 | const id = this.id; 370 | const secret = this.#session_engine.options.cookie.secret; 371 | if (id) this.#signed_id = this.#request.sign(id, secret); 372 | 373 | return this.#signed_id; 374 | } 375 | 376 | /** 377 | * Returns whether session is ready and its data has been retrieved. 378 | * @returns {Boolean} 379 | */ 380 | get ready() { 381 | return this.#ready; 382 | } 383 | 384 | /** 385 | * Returns whether session has already been stored in database or not. 386 | * This is helpful for choosing between INSERT/UPDATE operations for SQL based implementations. 387 | * @returns {Boolean} 388 | */ 389 | get stored() { 390 | return this.#from_database; 391 | } 392 | 393 | /** 394 | * Returns the current session's lifetime duration in milliseconds. 395 | * @returns {Number} 396 | */ 397 | get duration() { 398 | const default_duration = this.#session_engine.options.duration; 399 | const custom_duration = this.#session_data[this.#prefixes.duration]; 400 | return typeof custom_duration == 'number' ? custom_duration : default_duration; 401 | } 402 | 403 | /** 404 | * Returns the expiry UNIX timestamp in milliseconds of current session. 405 | * @returns {Number} 406 | */ 407 | get expires_at() { 408 | return Date.now() + this.duration; 409 | } 410 | } 411 | 412 | module.exports = Session; 413 | --------------------------------------------------------------------------------