├── .gitattributes ├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── .npmrc ├── LICENSE ├── README.md ├── benchmark └── bench.js ├── eslint.config.js ├── examples ├── memory.js └── redis.js ├── lib ├── cookie.js ├── fastifySession.js ├── idGenerator.js ├── session.js └── store.js ├── package.json ├── test ├── TestStore.js ├── base.test.js ├── cookie.test.js ├── expiration.test.js ├── fastifySession.checkOptions.test.js ├── idGenerator.test.js ├── memorystore.test.js ├── session.test.js ├── store.test.js ├── util.js └── verifyPath.test.js └── types ├── types.d.ts └── types.test-d.ts /.gitattributes: -------------------------------------------------------------------------------- 1 | # Set default behavior to automatically convert line endings 2 | * text=auto eol=lf 3 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | open-pull-requests-limit: 10 8 | 9 | - package-ecosystem: "npm" 10 | directory: "/" 11 | schedule: 12 | interval: "monthly" 13 | open-pull-requests-limit: 10 14 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - next 8 | - 'v*' 9 | paths-ignore: 10 | - 'docs/**' 11 | - '*.md' 12 | pull_request: 13 | paths-ignore: 14 | - 'docs/**' 15 | - '*.md' 16 | 17 | permissions: 18 | contents: read 19 | 20 | jobs: 21 | test: 22 | permissions: 23 | contents: write 24 | pull-requests: write 25 | uses: fastify/workflows/.github/workflows/plugins-ci.yml@v5 26 | with: 27 | license-check: true 28 | lint: true 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | 132 | # Vim swap files 133 | *.swp 134 | 135 | # macOS files 136 | .DS_Store 137 | 138 | # Clinic 139 | .clinic 140 | 141 | # lock files 142 | bun.lockb 143 | package-lock.json 144 | pnpm-lock.yaml 145 | yarn.lock 146 | 147 | # editor files 148 | .vscode 149 | .idea 150 | 151 | #tap files 152 | .tap/ 153 | 154 | # session files from benchmark 155 | sessions/ 156 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2017-2020 Denis Fäcke 4 | Copyright (c) 2021-2024 The Fastify Team 5 | 6 | The Fastify team members are listed at https://github.com/fastify/fastify#team 7 | and in the README file. 8 | 9 | Permission is hereby granted, free of charge, to any person obtaining 10 | a copy of this software and associated documentation files (the 11 | 'Software'), to deal in the Software without restriction, including 12 | without limitation the rights to use, copy, modify, merge, publish, 13 | distribute, sublicense, and/or sell copies of the Software, and to 14 | permit persons to whom the Software is furnished to do so, subject to 15 | the following conditions: 16 | 17 | The above copyright notice and this permission notice shall be 18 | included in all copies or substantial portions of the Software. 19 | 20 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 21 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 22 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 23 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 24 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 25 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 26 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @fastify/session 2 | 3 | [![CI](https://github.com/fastify/session/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/fastify/session/actions/workflows/ci.yml) 4 | [![NPM version](https://img.shields.io/npm/v/@fastify/session.svg?style=flat)](https://www.npmjs.com/package/@fastify/session) 5 | [![neostandard javascript style](https://img.shields.io/badge/code_style-neostandard-brightgreen?style=flat)](https://github.com/neostandard/neostandard) 6 | 7 | A session plugin for [fastify](http://fastify.dev/). 8 | Requires the [@fastify/cookie](https://github.com/fastify/fastify-cookie) plugin. 9 | 10 | **NOTE:** This is the continuation of [fastify-session](https://github.com/SerayaEryn/fastify-session) which is unmaintained by now. All work credit till [`e201f7`](https://github.com/fastify/session/commit/e201f78fc9d7bd33c6f2e84988be7c8af4b5a8a3) commit goes to [SerayaEryn](https://github.com/SerayaEryn) and contributors. 11 | 12 | ## Install 13 | 14 | ``` 15 | npm i @fastify/session 16 | ``` 17 | 18 | ## Usage 19 | 20 | ```js 21 | const fastify = require('fastify'); 22 | const fastifySession = require('@fastify/session'); 23 | const fastifyCookie = require('@fastify/cookie'); 24 | 25 | const app = fastify(); 26 | app.register(fastifyCookie); 27 | app.register(fastifySession, {secret: 'a secret with minimum length of 32 characters'}); 28 | ``` 29 | Store data in the session by adding it to the `session` decorator at the `request`: 30 | ```js 31 | app.register(fastifySession, {secret: 'a secret with minimum length of 32 characters'}); 32 | app.addHook('preHandler', (request, reply, next) => { 33 | request.session.user = {name: 'max'}; 34 | next(); 35 | }) 36 | ``` 37 | **NOTE**: For all unencrypted (HTTP) connections, you need to set the `secure` cookie option to `false`. See below for all cookie options and their details. 38 | The `session` object has methods that allow you to get, save, reload, and delete sessions. 39 | ```js 40 | app.register(fastifySession, {secret: 'a secret with minimum length of 32 characters'}); 41 | app.addHook('preHandler', (request, reply, next) => { 42 | request.session.destroy(next); 43 | }) 44 | ``` 45 | 46 | ## Examples 47 | 48 | * [Authentication](https://github.com/fastify/example/tree/main/fastify-session-authentication) 49 | 50 | ## API 51 | ### session(fastify, options, next) 52 | The session plugin accepts the following options. It decorates the request with the `sessionStore` and a `session` object. The session data is stored server-side using the configured session store. 53 | #### options 54 | ##### secret (required) 55 | The secret used to sign the cookie. Must be an array of strings or a string with a length of 32 or greater or a custom signer. 56 | 57 | If an array, the first secret is used to sign new cookies and is the first to be checked for incoming cookies. 58 | Further secrets in the array are used to check incoming cookies in the order specified. 59 | 60 | For a custom signer see the documentation of [@fastify/cookie](https://github.com/fastify/fastify-cookie#custom-cookie-signer) 61 | 62 | Note that the rest of the application may manipulate the array during its life cycle. This can be done by storing the array in a separate variable that is later used with mutating methods like unshift(), pop(), splice(), etc. 63 | This can be used to rotate the signing secret at regular intervals. A secret should remain somewhere in the array as long as there are active sessions with cookies signed by it. Secrets management is left up to the rest of the application. 64 | ##### cookieName (optional) 65 | The name of the session cookie. Defaults to `sessionId`. 66 | ##### cookiePrefix (optional) 67 | Prefix for the value of the cookie. This is useful for compatibility with `express-session`, which prefixes all cookies with `"s:"`. Defaults to `""`. 68 | ##### cookie 69 | The options object is used to generate the `Set-Cookie` header of the session cookie. May have the following properties: 70 | * `path` - The `Path` attribute. Defaults to `/` (the root path). 71 | * `maxAge` - A `number` in milliseconds that specifies the `Expires` attribute by adding the specified milliseconds to the current date. If both `expires` and `maxAge` are set, then `maxAge` is used. 72 | * `httpOnly` - The `boolean` value of the `HttpOnly` attribute. Defaults to true. 73 | * `secure` - The `boolean` value of the `Secure` attribute. Set this option to false when communicating over an unencrypted (HTTP) connection. Value can be set to `auto`; in this case, the `Secure` attribute will be set to false for an HTTP request. In the case of HTTPS, it will be set to true. Defaults to true. 74 | * `expires` - The expiration `date` used for the `Expires` attribute. If both `expires` and `maxAge` are set, then `maxAge` is used. 75 | * `sameSite`- The `boolean` or `string` of the `SameSite` attribute. Using `Secure` mode with `auto` attribute will change the behavior of the `SameSite` attribute in `http` mode. The `SameSite` attribute will automatically be set to `Lax` with an `http` request. See this [link](https://www.chromium.org/updates/same-site). 76 | * `domain` - The `Domain` attribute. 77 | * `partitioned` (**experimental**) - The `boolean` value of the `Partitioned` attribute. Using the Partitioned attribute as part of Cookies Having Independent Partitioned State (CHIPS) to allow cross-site access with a separate cookie used per site. Defaults to false. 78 | 79 | ##### store 80 | A session store. Needs the following methods: 81 | * set(sessionId, session, callback) 82 | * get(sessionId, callback) 83 | * destroy(sessionId, callback) 84 | 85 | Compatible with stores from [express-session](https://github.com/expressjs/session). 86 | 87 | If you are terminating HTTPs at 88 | the reverse proxy, you need to add the `trustProxy` setting to your fastify instance if you want to use secure cookies. 89 | 90 | Defaults to a simple in-memory store.
91 | **Note**: The default store should not be used in a production environment because it will leak memory. 92 | 93 | ##### saveUninitialized (optional) 94 | Save sessions to the store, even when they are new and not modified— defaults to `true`. 95 | Setting this to `false` can save storage space and comply with the EU cookie law. 96 | 97 | ##### rolling (optional) 98 | Forces the session identifier cookie to be set on every response. The expiration is reset to the original maxAge - effectively resetting the cookie lifetime. This is typically used in conjunction with short, non-session-length maxAge values to provide a quick expiration of the session data with reduced potential of session expiration occurring during ongoing server interactions. Defaults to true. 99 | 100 | ##### idGenerator(request) (optional) 101 | 102 | Function used to generate new session IDs. 103 | Custom implementation example: 104 | ```js 105 | const uid = require('uid-safe').sync 106 | 107 | idGenerator: (request) => { 108 | if (request.session.returningVisitor) return `returningVisitor-${uid(24)}` 109 | else return uid(24) 110 | } 111 | ``` 112 | 113 | #### request.session 114 | 115 | Allows to access or modify the session data. 116 | 117 | #### Session#destroy(callback) 118 | 119 | Allows to destroy the session in the store. If you do not pass a callback, a Promise will be returned. 120 | 121 | #### Session#touch() 122 | 123 | Updates the `expires` property of the session's cookie. 124 | 125 | #### Session#options(opts) 126 | 127 | Updates default options for setCookie inside a route. 128 | 129 | ```js 130 | fastify.post('/', (request, reply) => { 131 | request.session.set('data', request.body) 132 | // .options takes any parameter that you can pass to setCookie 133 | request.session.options({ maxAge: 60 * 60 }); // 3600 seconds => maxAge is always passed in seconds 134 | reply.send('hello world') 135 | }) 136 | ``` 137 | 138 | #### Session#regenerate([ignoreFields, ]callback) 139 | 140 | Regenerates the session by generating a new `sessionId` and persist it to the store. If you do not pass a callback, a Promise will be returned. 141 | ```js 142 | fastify.get('/regenerate', (request, reply, done) => { 143 | request.session.regenerate(error => { 144 | if (error) { 145 | done(error); 146 | return; 147 | } 148 | reply.send(request.session.sessionId); 149 | }); 150 | }); 151 | ``` 152 | 153 | You can pass an array of fields that should be kept when the session is regenerated. 154 | 155 | #### Session#reload(callback) 156 | 157 | Reloads the session data from the store and re-populates the `request.session` object. If you do not pass a callback, a Promise will be returned. 158 | 159 | #### Session#save(callback) 160 | 161 | Save the session back to the store, replacing the contents on the store with the contents in memory. If you do not pass a callback, a Promise will be returned. 162 | 163 | #### Session#get(key) 164 | 165 | Gets a value from the session 166 | 167 | #### Session#set(key, value) 168 | 169 | Sets a value in the session 170 | 171 | #### Session#isModified() 172 | 173 | Whether the session has been modified from what was loaded from the store (or created) 174 | 175 | #### Session#isSaved() 176 | 177 | Whether the session (and any of its potential modifications) has persisted in the store. 178 | 179 | ### fastify.decryptSession(sessionId, request, cookieOptions, next) 180 | This plugin also decorates the fastify instance with `decryptSession` in case you want to decrypt the session manually. 181 | 182 | ```js 183 | const { sessionId } = fastify.parseCookie(cookieHeader); 184 | const request = {} 185 | fastify.decryptSession(sessionId, request, () => { 186 | // request.session should be available here 187 | }) 188 | 189 | // or decrypt with custom cookie options: 190 | fastify.decryptSession(sessionId, request, { maxAge: 86400 }, () => { 191 | // ... 192 | }) 193 | ``` 194 | 195 | ### Typescript support: 196 | This plugin supports typescript, and you can extend the fastify module to add your custom session type. 197 | 198 | ```ts 199 | // Use module imports rather than commonjs' require for correct declaration merging in TypeScript. 200 | 201 | // Wrong ❌: 202 | // const fastifySession = require('@fastify/session'); 203 | // const fastifyCookie = require('@fastify/cookie'); 204 | 205 | // Correct ✔️: 206 | import { fastifySession } from '@fastify/session'; 207 | import { fastifyCookie } from '@fastify/cookie'; 208 | 209 | // Extend fastify.session with your custom type. 210 | declare module "fastify" { 211 | interface Session { 212 | user_id: string 213 | other_key: your_prefer_type 214 | id?: number 215 | } 216 | } 217 | ``` 218 | 219 | When you think that the getter or setter is too strict. 220 | You are allowed to use `any` types to loosen the check. 221 | 222 | ```ts 223 | fastify.get('/', async function(request) { 224 | request.session.get('not-exist') 225 | request.session.set('not-exist', 'happy') 226 | }) 227 | ``` 228 | 229 | ## License 230 | 231 | Licensed under [MIT](./LICENSE). 232 | -------------------------------------------------------------------------------- /benchmark/bench.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const RedisStore = require('connect-redis').default 4 | const Fastify = require('fastify') 5 | const Redis = require('ioredis') 6 | const fileStoreFactory = require('session-file-store') 7 | const { isMainThread } = require('node:worker_threads') 8 | 9 | const fastifySession = require('..') 10 | const fastifyCookie = require('@fastify/cookie') 11 | 12 | let redisClient 13 | 14 | function createServer (sessionPlugin, cookiePlugin, storeType) { 15 | let requestCounter = 0 16 | let store 17 | 18 | if (storeType === 'redis') { 19 | if (!redisClient) { 20 | redisClient = new Redis() 21 | } 22 | store = new RedisStore({ client: redisClient }) 23 | } else if (storeType === 'file') { 24 | const FileStore = fileStoreFactory(sessionPlugin) 25 | store = new FileStore({}) 26 | } 27 | 28 | const fastify = Fastify() 29 | 30 | fastify.register(cookiePlugin) 31 | fastify.register(sessionPlugin, { 32 | secret: 'cNaoPYAwF60HZJzkcNaoPYAwF60HZJzk', 33 | saveUninitialized: false, 34 | cookie: { secure: false }, 35 | store 36 | }) 37 | 38 | fastify.get('/', (request, reply) => { 39 | // modify session every 10 requests 40 | if (requestCounter % 10 === 0) { 41 | request.session.userId = requestCounter 42 | } else { 43 | request.session.userId = 0 44 | } 45 | 46 | requestCounter++ 47 | 48 | reply.send(200) 49 | }) 50 | 51 | return fastify 52 | } 53 | 54 | function testFunction (sessionPlugin, cookiePlugin, storeType) { 55 | const server = createServer(sessionPlugin, cookiePlugin, storeType) 56 | 57 | return async function () { 58 | const { headers } = await server.inject('/') 59 | const setCookieHeader = headers['set-cookie'] 60 | 61 | if (!setCookieHeader) { 62 | throw new Error('Missing set-cookie header') 63 | } 64 | 65 | const { sessionId } = server.parseCookie(setCookieHeader) 66 | 67 | // make 25 "requests" with the new session 68 | await Promise.all( 69 | new Array(1).fill(0).map(() => server.inject({ path: '/', headers: { cookie: `sessionId=${sessionId}` } })) 70 | ) 71 | } 72 | } 73 | 74 | async function main () { 75 | const { default: cronometro } = await import('cronometro') 76 | 77 | return cronometro( 78 | { 79 | memory: testFunction(fastifySession, fastifyCookie), 80 | file: testFunction(fastifySession, fastifyCookie, 'file'), 81 | redis: { 82 | test: testFunction(fastifySession, fastifyCookie, 'redis'), 83 | async after () { 84 | return redisClient.disconnect() 85 | } 86 | } 87 | }, 88 | { 89 | iterations: 25, 90 | print: { compare: true } 91 | } 92 | ) 93 | } 94 | 95 | if (isMainThread) { 96 | main() 97 | .then(() => redisClient.quit()) 98 | .catch(error => console.error(error)) 99 | } else { 100 | module.exports = main 101 | } 102 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = require('neostandard')({ 4 | ignores: require('neostandard').resolveIgnoresFromGitignore(), 5 | ts: true 6 | }) 7 | -------------------------------------------------------------------------------- /examples/memory.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Fastify = require('fastify') 4 | const fastifySession = require('..') 5 | const fastifyCookie = require('@fastify/cookie') 6 | 7 | const fastify = Fastify() 8 | 9 | fastify.register(fastifyCookie, {}) 10 | fastify.register(fastifySession, { 11 | secret: 'cNaoPYAwF60HZJzkcNaoPYAwF60HZJzk', 12 | cookie: { secure: false } 13 | }) 14 | 15 | fastify.get('/', (request, reply) => { 16 | reply 17 | .send(request.cookies.sessionId) 18 | }) 19 | 20 | const response = fastify.inject('/') 21 | response.then(v => console.log(` 22 | 23 | autocannon -H "Cookie=${decodeURIComponent(v.headers['set-cookie'])}" http://127.0.0.1:3000`)) 24 | 25 | fastify.listen({ port: 3000 }) 26 | -------------------------------------------------------------------------------- /examples/redis.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Fastify = require('fastify') 4 | const fastifySession = require('..') 5 | const fastifyCookie = require('@fastify/cookie') 6 | const Redis = require('ioredis') 7 | const RedisStore = require('connect-redis').default 8 | 9 | const fastify = Fastify() 10 | 11 | const store = new RedisStore({ 12 | client: new Redis({ 13 | enableAutoPipelining: true 14 | }) 15 | }) 16 | 17 | fastify.register(fastifyCookie, {}) 18 | fastify.register(fastifySession, { 19 | secret: 'cNaoPYAwF60HZJzkcNaoPYAwF60HZJzk', 20 | cookie: { secure: false }, 21 | store 22 | }) 23 | 24 | fastify.get('/', (request, reply) => { 25 | reply 26 | .send(request.cookies.sessionId) 27 | }) 28 | 29 | const response = fastify.inject('/') 30 | response.then(v => console.log(` 31 | 32 | autocannon -p 10 -H "Cookie=${decodeURIComponent(v.headers['set-cookie'])}" http://127.0.0.1:3000`)) 33 | 34 | fastify.listen({ port: 3000 }) 35 | -------------------------------------------------------------------------------- /lib/cookie.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = class Cookie { 4 | constructor (cookie, request) { 5 | const originalMaxAge = cookie.originalMaxAge || cookie.maxAge || null 6 | this.path = cookie.path || '/' 7 | this.secure = cookie.secure ?? null 8 | this.sameSite = cookie.sameSite || null 9 | this.domain = cookie.domain || null 10 | this.httpOnly = cookie.httpOnly !== undefined ? cookie.httpOnly : true 11 | this.partitioned = cookie.partitioned 12 | this._expires = null 13 | 14 | if (cookie.expires) { 15 | this.originalExpires = new Date(cookie.expires) 16 | } 17 | 18 | if (originalMaxAge) { 19 | this.maxAge = originalMaxAge 20 | } else if (cookie.expires) { 21 | this.expires = new Date(cookie.expires) 22 | this.originalMaxAge = null 23 | } else { 24 | this.originalMaxAge = originalMaxAge 25 | } 26 | 27 | if (this.secure === 'auto') { 28 | if (request.protocol === 'https') { 29 | this.secure = true 30 | } else { 31 | this.sameSite = 'lax' 32 | this.secure = false 33 | } 34 | } 35 | } 36 | 37 | set expires (date) { 38 | this._expires = date 39 | } 40 | 41 | get expires () { 42 | return this._expires 43 | } 44 | 45 | set maxAge (ms) { 46 | this.expires = new Date(Date.now() + ms) 47 | // we force the same originalMaxAge to match old behavior 48 | this.originalMaxAge = ms 49 | } 50 | 51 | get maxAge () { 52 | if (this.expires instanceof Date) { 53 | return this.expires.valueOf() - Date.now() 54 | } else { 55 | return null 56 | } 57 | } 58 | 59 | toJSON () { 60 | return { 61 | expires: this._expires, 62 | originalMaxAge: this.originalMaxAge, 63 | originalExpires: this.originalExpires, 64 | sameSite: this.sameSite, 65 | secure: this.secure, 66 | path: this.path, 67 | httpOnly: this.httpOnly, 68 | domain: this.domain, 69 | partitioned: this.partitioned 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /lib/fastifySession.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const fp = require('fastify-plugin') 4 | const idGenerator = require('./idGenerator')() 5 | const Store = require('./store') 6 | const Session = require('./session') 7 | 8 | function fastifySession (fastify, options, next) { 9 | const error = checkOptions(options) 10 | if (error) { 11 | return next(error) 12 | } 13 | 14 | options = ensureDefaults(options) 15 | 16 | const sessionStore = options.store 17 | const cookieSigner = options.signer 18 | const cookieName = options.cookieName 19 | const cookiePrefix = options.cookiePrefix 20 | const hasCookiePrefix = typeof cookiePrefix === 'string' && cookiePrefix.length !== 0 21 | const cookiePrefixLength = hasCookiePrefix && cookiePrefix.length 22 | 23 | // Decorator function takes cookieOpts so we can customize on per-session basis. 24 | fastify.decorate('decryptSession', (sessionId, request, cookieOpts, callback) => { 25 | if (typeof cookieOpts === 'function') { 26 | callback = cookieOpts 27 | cookieOpts = {} 28 | } 29 | 30 | const cookie = { ...options.cookie, ...cookieOpts } 31 | decryptSession(sessionId, { ...options, cookie }, request, callback) 32 | }) 33 | fastify.decorateRequest('sessionStore', { getter: () => sessionStore }) 34 | fastify.decorateRequest('session', null) 35 | fastify.addHook('onRequest', onRequest(options)) 36 | fastify.addHook('onSend', onSend(options)) 37 | next() 38 | 39 | function decryptSession (sessionId, options, request, done) { 40 | const cookieOpts = options.cookie 41 | const idGenerator = options.idGenerator 42 | 43 | const unsignedCookie = cookieSigner.unsign(sessionId) 44 | if (unsignedCookie.valid === false) { 45 | request.session = new Session( 46 | sessionStore, 47 | request, 48 | idGenerator, 49 | cookieOpts, 50 | cookieSigner 51 | ) 52 | done() 53 | return 54 | } 55 | const decryptedSessionId = unsignedCookie.value 56 | sessionStore.get(decryptedSessionId, (err, session) => { 57 | if (err) { 58 | if (err.code === 'ENOENT') { 59 | request.session = new Session( 60 | sessionStore, 61 | request, 62 | idGenerator, 63 | cookieOpts, 64 | cookieSigner 65 | ) 66 | done() 67 | } else { 68 | done(err) 69 | } 70 | return 71 | } 72 | 73 | if (!session) { 74 | request.session = new Session( 75 | sessionStore, 76 | request, 77 | idGenerator, 78 | cookieOpts, 79 | cookieSigner 80 | ) 81 | done() 82 | return 83 | } 84 | 85 | const restoredSession = new Session( 86 | sessionStore, 87 | request, 88 | idGenerator, 89 | cookieOpts, 90 | cookieSigner, 91 | session, 92 | decryptedSessionId 93 | ) 94 | 95 | const expiration = restoredSession.cookie.originalExpires || restoredSession.cookie.expires 96 | 97 | if (expiration && expiration.getTime() <= Date.now()) { 98 | restoredSession.destroy(err => { 99 | if (err) { 100 | done(err) 101 | return 102 | } 103 | 104 | restoredSession.regenerate(done) 105 | }) 106 | return 107 | } 108 | 109 | request.session = restoredSession 110 | done() 111 | }) 112 | } 113 | 114 | const getCookieSessionId = hasCookiePrefix 115 | ? function getCookieSessionId (request) { 116 | const cookieValue = request.cookies[cookieName] 117 | return ( 118 | cookieValue?.startsWith(cookiePrefix) && 119 | cookieValue.slice(cookiePrefixLength) 120 | ) 121 | } 122 | : function getCookieSessionId (request) { 123 | return request.cookies[cookieName] 124 | } 125 | 126 | function onRequest (options) { 127 | const cookieOpts = options.cookie 128 | const idGenerator = options.idGenerator 129 | 130 | return function handleSession (request, _reply, done) { 131 | request.session = {} 132 | 133 | const url = request.raw.url.split('?', 1)[0] 134 | if (verifyPath(url, cookieOpts.path || '/') === false) { 135 | done() 136 | return 137 | } 138 | 139 | const cookieSessionId = getCookieSessionId(request) 140 | if (!cookieSessionId) { 141 | request.session = new Session( 142 | sessionStore, 143 | request, 144 | idGenerator, 145 | cookieOpts, 146 | cookieSigner 147 | ) 148 | done() 149 | } else { 150 | decryptSession(cookieSessionId, options, request, done) 151 | } 152 | } 153 | } 154 | 155 | function onSend (options) { 156 | const cookieOpts = options.cookie 157 | const saveUninitializedSession = options.saveUninitialized 158 | const rollingSessions = options.rolling 159 | 160 | return function saveSession (request, reply, _payload, done) { 161 | const session = request.session 162 | if (!session || !session.sessionId || !session.encryptedSessionId) { 163 | done() 164 | return 165 | } 166 | 167 | const cookieSessionId = getCookieSessionId(request) 168 | const saveSession = shouldSaveSession(request, cookieSessionId, saveUninitializedSession, rollingSessions) 169 | const isInsecureConnection = cookieOpts.secure === true && request.protocol !== 'https' 170 | const sessionIdWithPrefix = hasCookiePrefix ? `${cookiePrefix}${session.encryptedSessionId}` : session.encryptedSessionId 171 | if (!saveSession || isInsecureConnection) { 172 | // if a session cookie is set, but has a different ID, clear it 173 | if (cookieSessionId && cookieSessionId !== session.encryptedSessionId) { 174 | reply.clearCookie(cookieName, { domain: cookieOpts.domain }) 175 | } 176 | 177 | if (session.isSaved()) { 178 | reply.setCookie( 179 | cookieName, 180 | sessionIdWithPrefix, 181 | // we need to remove extra properties to align the same with `express-session` 182 | session.cookie.toJSON() 183 | ) 184 | } 185 | 186 | done() 187 | return 188 | } 189 | 190 | session.save((err) => { 191 | if (err) { 192 | done(err) 193 | return 194 | } 195 | reply.setCookie( 196 | cookieName, 197 | sessionIdWithPrefix, 198 | // we need to remove extra properties to align the same with `express-session` 199 | session.cookie.toJSON() 200 | ) 201 | done() 202 | }) 203 | } 204 | } 205 | 206 | function checkOptions (options) { 207 | if (typeof options.secret === 'string') { 208 | if (options.secret.length < 32) { 209 | return new Error('the secret must have length 32 or greater') 210 | } 211 | } else if (Array.isArray(options.secret)) { 212 | if (options.secret.length === 0) { 213 | return new Error('at least one secret is required') 214 | } 215 | } else if (!(options.secret && typeof options.secret.sign === 'function' && typeof options.secret.unsign === 'function')) { 216 | return new Error('the secret option is required, and must be a String, Array of Strings, or a signer object with .sign and .unsign methods') 217 | } 218 | } 219 | 220 | function ensureDefaults (options) { 221 | const opts = {} 222 | opts.store = options.store || new Store() 223 | opts.idGenerator = options.idGenerator || idGenerator 224 | opts.cookieName = options.cookieName || 'sessionId' 225 | opts.cookie = options.cookie || {} 226 | opts.cookie.secure = option(opts.cookie, 'secure', true) 227 | opts.rolling = option(options, 'rolling', true) 228 | opts.saveUninitialized = option(options, 'saveUninitialized', true) 229 | opts.algorithm = options.algorithm || 'sha256' 230 | opts.signer = typeof options.secret === 'string' || Array.isArray(options.secret) 231 | ? new (require('@fastify/cookie').Signer)(options.secret, opts.algorithm) 232 | : options.secret 233 | opts.cookiePrefix = option(options, 'cookiePrefix', '') 234 | return opts 235 | } 236 | 237 | function shouldSaveSession (request, cookieId, saveUninitializedSession, rollingSessions) { 238 | return cookieId !== request.session.encryptedSessionId 239 | ? saveUninitializedSession || request.session.isModified() 240 | : rollingSessions || request.session.isModified() 241 | } 242 | 243 | function option (options, key, def) { 244 | return options[key] === undefined ? def : options[key] 245 | } 246 | 247 | function verifyPath (path, cookiePath) { 248 | if (path === cookiePath) { 249 | return true 250 | } 251 | const pathLength = path.length 252 | const cookiePathLength = cookiePath.length 253 | 254 | if (pathLength <= cookiePathLength) { 255 | return false 256 | } else if (path.startsWith(cookiePath)) { 257 | if (path[cookiePathLength] === '/') { 258 | return true 259 | } else if (cookiePath[cookiePathLength - 1] === '/') { 260 | return true 261 | } 262 | } 263 | return false 264 | } 265 | } 266 | 267 | module.exports = fp(fastifySession, { 268 | fastify: '5.x', 269 | name: '@fastify/session', 270 | dependencies: [ 271 | '@fastify/cookie' 272 | ] 273 | }) 274 | module.exports.default = fastifySession 275 | module.exports.fastifySession = fastifySession 276 | 277 | module.exports.Store = Store 278 | module.exports.MemoryStore = Store 279 | -------------------------------------------------------------------------------- /lib/idGenerator.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const randomBytes = require('node:crypto').randomBytes 4 | 5 | const cacheSize = 24 << 7 6 | let pos = 0 7 | let cache = randomBytes(cacheSize) 8 | 9 | const EQUAL_END_REGEXP = /=/g 10 | const PLUS_GLOBAL_REGEXP = /\+/g 11 | const SLASH_GLOBAL_REGEXP = /\//g 12 | 13 | module.exports = function (useBase64Url = Buffer.isEncoding('base64url')) { 14 | return useBase64Url 15 | ? function idGenerator () { 16 | if ((pos + 24) > cacheSize) { 17 | cache = randomBytes(cacheSize) 18 | pos = 0 19 | } 20 | const buf = Buffer.allocUnsafe(24) 21 | cache.copy(buf, 0, pos, (pos += 24)) 22 | return buf.toString('base64url') 23 | } 24 | : function idGenerator () { 25 | if ((pos + 24) > cacheSize) { 26 | cache = randomBytes(cacheSize) 27 | pos = 0 28 | } 29 | const buf = Buffer.allocUnsafe(24) 30 | cache.copy(buf, 0, pos, (pos += 24)) 31 | return buf.toString('base64') 32 | .replace(EQUAL_END_REGEXP, '') 33 | .replace(PLUS_GLOBAL_REGEXP, '-') 34 | .replace(SLASH_GLOBAL_REGEXP, '_') 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /lib/session.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const crypto = require('node:crypto') 4 | 5 | const Cookie = require('./cookie') 6 | const { configure: configureStringifier } = require('safe-stable-stringify') 7 | 8 | const stringify = configureStringifier({ bigint: false }) 9 | 10 | const cookieSignerKey = Symbol('cookieSignerKey') 11 | const generateId = Symbol('generateId') 12 | const requestKey = Symbol('request') 13 | const cookieOptsKey = Symbol('cookieOpts') 14 | const persistedHash = Symbol('persistedHash') 15 | const hash = Symbol('hash') 16 | const sessionIdKey = Symbol('sessionId') 17 | const sessionStoreKey = Symbol('sessionStore') 18 | const encryptedSessionIdKey = Symbol('encryptedSessionId') 19 | const savedKey = Symbol('saved') 20 | 21 | module.exports = class Session { 22 | constructor ( 23 | sessionStore, 24 | request, 25 | idGenerator, 26 | cookieOpts, 27 | cookieSigner, 28 | prevSession, 29 | sessionId = idGenerator(request) 30 | ) { 31 | this[sessionStoreKey] = sessionStore 32 | this[generateId] = idGenerator 33 | this[cookieOptsKey] = cookieOpts 34 | this[cookieSignerKey] = cookieSigner 35 | this[requestKey] = request 36 | this[sessionIdKey] = sessionId 37 | this[encryptedSessionIdKey] = ( 38 | prevSession && 39 | prevSession[sessionIdKey] === sessionId && 40 | prevSession[encryptedSessionIdKey] 41 | ) || cookieSigner.sign(this.sessionId) 42 | this[savedKey] = false 43 | this.cookie = new Cookie(prevSession?.cookie || cookieOpts, request) 44 | 45 | if (prevSession) { 46 | // Copy over values from the previous session 47 | for (const key in prevSession) { 48 | ( 49 | key !== 'cookie' && 50 | key !== 'sessionId' && 51 | key !== 'encryptedSessionId' 52 | ) && (this[key] = prevSession[key]) 53 | } 54 | } 55 | 56 | this[persistedHash] = this[hash]() 57 | } 58 | 59 | options (opts) { 60 | if (Object.keys(opts).length) { 61 | Object.assign(this[cookieOptsKey], opts) 62 | this.cookie = new Cookie(this[cookieOptsKey], this[requestKey]) 63 | } 64 | } 65 | 66 | touch () { 67 | if (this.cookie.originalMaxAge) { 68 | this.cookie.expires = new Date(Date.now() + this.cookie.originalMaxAge) 69 | } 70 | } 71 | 72 | regenerate (keys, callback) { 73 | if (typeof keys === 'function') { 74 | callback = keys 75 | keys = undefined 76 | } 77 | const session = new Session( 78 | this[sessionStoreKey], 79 | this[requestKey], 80 | this[generateId], 81 | this[cookieOptsKey], 82 | this[cookieSignerKey] 83 | ) 84 | 85 | if (Array.isArray(keys)) { 86 | for (const key of keys) { 87 | session.set(key, this[key]) 88 | } 89 | } 90 | 91 | if (callback) { 92 | this[sessionStoreKey].set(session.sessionId, session, error => { 93 | this[requestKey].session = session 94 | 95 | callback(error) 96 | }) 97 | } else { 98 | return new Promise((resolve, reject) => { 99 | this[sessionStoreKey].set(session.sessionId, session, error => { 100 | this[requestKey].session = session 101 | 102 | if (error) { 103 | reject(error) 104 | } else { 105 | resolve() 106 | } 107 | }) 108 | }) 109 | } 110 | } 111 | 112 | get (key) { 113 | return this[key] 114 | } 115 | 116 | set (key, value) { 117 | this[key] = value 118 | } 119 | 120 | destroy (callback) { 121 | if (callback) { 122 | this[sessionStoreKey].destroy(this[sessionIdKey], error => { 123 | this[requestKey].session = null 124 | 125 | callback(error) 126 | }) 127 | } else { 128 | return new Promise((resolve, reject) => { 129 | this[sessionStoreKey].destroy(this[sessionIdKey], error => { 130 | this[requestKey].session = null 131 | 132 | if (error) { 133 | reject(error) 134 | } else { 135 | resolve() 136 | } 137 | }) 138 | }) 139 | } 140 | } 141 | 142 | reload (callback) { 143 | if (callback) { 144 | this[sessionStoreKey].get(this[sessionIdKey], (error, session) => { 145 | this[requestKey].session = new Session(this[requestKey], 146 | this[sessionStoreKey], 147 | this[generateId], 148 | this[cookieOptsKey], 149 | this[cookieSignerKey], 150 | session, 151 | this[sessionIdKey] 152 | ) 153 | 154 | callback(error) 155 | }) 156 | } else { 157 | return new Promise((resolve, reject) => { 158 | this[sessionStoreKey].get(this[sessionIdKey], (error, session) => { 159 | this[requestKey].session = new Session( 160 | this[sessionStoreKey], 161 | this[requestKey], 162 | this[generateId], 163 | this[cookieOptsKey], 164 | this[cookieSignerKey], 165 | session, 166 | this[sessionIdKey] 167 | ) 168 | 169 | if (error) { 170 | reject(error) 171 | } else { 172 | resolve() 173 | } 174 | }) 175 | }) 176 | } 177 | } 178 | 179 | save (callback) { 180 | if (callback) { 181 | this[sessionStoreKey].set(this[sessionIdKey], this, error => { 182 | if (error) { 183 | callback(error) 184 | } else { 185 | this[savedKey] = true 186 | this[persistedHash] = this[hash]() 187 | callback() 188 | } 189 | }) 190 | } else { 191 | return new Promise((resolve, reject) => { 192 | this[sessionStoreKey].set(this[sessionIdKey], this, error => { 193 | if (error) { 194 | reject(error) 195 | } else { 196 | this[savedKey] = true 197 | this[persistedHash] = this[hash]() 198 | resolve() 199 | } 200 | }) 201 | }) 202 | } 203 | } 204 | 205 | get sessionId () { 206 | return this[sessionIdKey] 207 | } 208 | 209 | get encryptedSessionId () { 210 | return this[encryptedSessionIdKey] 211 | } 212 | 213 | [hash] () { 214 | const sess = this 215 | const str = stringify(sess, function (key, val) { 216 | // ignore sess.cookie property 217 | if (this === sess && key === 'cookie') { 218 | return 219 | } 220 | 221 | return val 222 | }) 223 | 224 | return crypto 225 | .createHash('sha256') 226 | .update(str, 'utf8') 227 | .digest('hex') 228 | } 229 | 230 | isModified () { 231 | return this[persistedHash] !== this[hash]() 232 | } 233 | 234 | isSaved () { 235 | return this[savedKey] 236 | } 237 | } 238 | -------------------------------------------------------------------------------- /lib/store.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const EventEmitter = require('node:events').EventEmitter 4 | const util = require('node:util') 5 | 6 | function Store (storeMap = new Map()) { 7 | this.store = storeMap 8 | EventEmitter.call(this) 9 | } 10 | 11 | util.inherits(Store, EventEmitter) 12 | 13 | Store.prototype.set = function set (sessionId, session, callback) { 14 | this.store.set(sessionId, session) 15 | callback() 16 | } 17 | 18 | Store.prototype.get = function get (sessionId, callback) { 19 | const session = this.store.get(sessionId) 20 | callback(null, session) 21 | } 22 | 23 | Store.prototype.destroy = function destroy (sessionId, callback) { 24 | this.store.delete(sessionId) 25 | callback() 26 | } 27 | 28 | module.exports = Store 29 | module.exports.default = Store 30 | module.exports.Store = Store 31 | module.exports.MemoryStore = Store 32 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fastify/session", 3 | "version": "11.1.0", 4 | "description": "a session plugin for fastify", 5 | "main": "lib/fastifySession.js", 6 | "type": "commonjs", 7 | "scripts": { 8 | "test": "npm run test:unit && npm run test:typescript", 9 | "test:unit": "c8 --100 node --test", 10 | "test:typescript": "tsd", 11 | "benchmark": "node benchmark/bench.js", 12 | "lint": "eslint", 13 | "lint:fix": "eslint --fix" 14 | }, 15 | "keywords": [ 16 | "session", 17 | "fastify" 18 | ], 19 | "author": "Denis Fäcke", 20 | "contributors": [ 21 | { 22 | "name": "Matteo Collina", 23 | "email": "hello@matteocollina.com" 24 | }, 25 | { 26 | "name": "Aras Abbasi", 27 | "email": "aras.abbasi@gmail.com" 28 | }, 29 | { 30 | "name": "Vincent Le Goff", 31 | "email": "vince.legoff@gmail.com", 32 | "url": "https://github.com/zekth" 33 | }, 34 | { 35 | "name": "Frazer Smith", 36 | "email": "frazer.dev@icloud.com", 37 | "url": "https://github.com/fdawgs" 38 | } 39 | ], 40 | "license": "MIT", 41 | "dependencies": { 42 | "fastify-plugin": "^5.0.1", 43 | "safe-stable-stringify": "^2.4.3" 44 | }, 45 | "repository": { 46 | "type": "git", 47 | "url": "git+https://github.com/fastify/session.git" 48 | }, 49 | "bugs": { 50 | "url": "https://github.com/fastify/session/issues" 51 | }, 52 | "homepage": "https://github.com/fastify/session#readme", 53 | "funding": [ 54 | { 55 | "type": "github", 56 | "url": "https://github.com/sponsors/fastify" 57 | }, 58 | { 59 | "type": "opencollective", 60 | "url": "https://opencollective.com/fastify" 61 | } 62 | ], 63 | "devDependencies": { 64 | "@fastify/cookie": "^11.0.0", 65 | "@fastify/pre-commit": "^2.1.0", 66 | "@types/node": "^22.0.0", 67 | "c8": "^10.1.2", 68 | "connect-mongo": "^5.1.0", 69 | "connect-redis": "^7.1.1", 70 | "cronometro": "^5.3.0", 71 | "eslint": "^9.17.0", 72 | "fastify": "^5.0.0", 73 | "ioredis": "^5.3.2", 74 | "neostandard": "^0.12.0", 75 | "session-file-store": "^1.5.0", 76 | "tsd": "^0.32.0" 77 | }, 78 | "types": "types/types.d.ts", 79 | "files": [ 80 | "lib", 81 | "types/types.d.ts" 82 | ], 83 | "pre-commit": [ 84 | "lint", 85 | "test" 86 | ] 87 | } 88 | -------------------------------------------------------------------------------- /test/TestStore.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { MemoryStore } = require('../lib/store') 4 | 5 | class TestStore extends MemoryStore { 6 | set (sessionId, session, callback) { 7 | this.store.set(sessionId, JSON.parse(JSON.stringify(session))) 8 | callback() 9 | } 10 | } 11 | 12 | module.exports = TestStore 13 | -------------------------------------------------------------------------------- /test/base.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const test = require('node:test') 4 | const Signer = require('@fastify/cookie').Signer 5 | const fastifyPlugin = require('fastify-plugin') 6 | const { DEFAULT_OPTIONS, DEFAULT_COOKIE, DEFAULT_SESSION_ID, DEFAULT_SECRET, DEFAULT_ENCRYPTED_SESSION_ID, buildFastify } = require('./util') 7 | const TestStore = require('./TestStore') 8 | const { setTimeout: sleep } = require('timers/promises') 9 | 10 | test('should not set session cookie on post without params', async (t) => { 11 | t.plan(3) 12 | const fastify = await buildFastify((_request, reply) => reply.send(200), DEFAULT_OPTIONS) 13 | t.after(() => fastify.close()) 14 | 15 | const response = await fastify.inject({ 16 | method: 'POST', 17 | url: '/test', 18 | headers: { 'content-type': 'application/json' } 19 | }) 20 | t.assert.strictEqual(response.statusCode, 400) 21 | t.assert.ok(response.body.includes('FST_ERR_CTP_EMPTY_JSON_BODY')) 22 | t.assert.strictEqual(response.headers['set-cookie'], undefined) 23 | }) 24 | 25 | test('should save the session properly', async (t) => { 26 | t.plan(11) 27 | const store = new TestStore() 28 | const fastify = await buildFastify((request, reply) => { 29 | request.session.test = true 30 | 31 | request.session.save(() => { 32 | const storeMap = store.store 33 | // Only one session 34 | t.assert.strictEqual(storeMap.size, 1) 35 | 36 | const session = [...storeMap.entries()][0][1] 37 | const keys = Object.keys(session) 38 | 39 | // Only storing two keys: cookie and test 40 | t.assert.strictEqual(keys.length, 2) 41 | t.assert.ok(keys.includes('cookie')) 42 | t.assert.ok(keys.includes('test')) 43 | t.assert.strictEqual(keys.includes('sessionId'), false) 44 | t.assert.strictEqual(keys.includes('encryptedSessionId'), false) 45 | 46 | t.assert.ok(session.cookie) 47 | t.assert.strictEqual(session.test, true) 48 | t.assert.strictEqual(session.sessionId, undefined) 49 | t.assert.strictEqual(session.encryptedSessionId, undefined) 50 | }) 51 | reply.send() 52 | }, { ...DEFAULT_OPTIONS, store }) 53 | t.after(() => fastify.close()) 54 | 55 | const response = await fastify.inject({ 56 | url: '/' 57 | }) 58 | t.assert.strictEqual(response.statusCode, 200) 59 | }) 60 | 61 | test('should set session cookie', async (t) => { 62 | t.plan(4) 63 | const fastify = await buildFastify((request, reply) => { 64 | request.session.test = {} 65 | reply.send(200) 66 | }, DEFAULT_OPTIONS) 67 | t.after(() => fastify.close()) 68 | 69 | const response1 = await fastify.inject({ 70 | url: '/', 71 | headers: { 'x-forwarded-proto': 'https' } 72 | }) 73 | 74 | t.assert.strictEqual(response1.statusCode, 200) 75 | const pattern1 = String.raw`sessionId=[\w-]{32}.[\w-%]{43,57}; Path=\/; HttpOnly; Secure` 76 | t.assert.strictEqual(new RegExp(pattern1).test(response1.headers['set-cookie']), true) 77 | 78 | const response2 = await fastify.inject({ 79 | url: '/', 80 | headers: { 'x-forwarded-proto': 'https' } 81 | }) 82 | 83 | t.assert.strictEqual(response2.statusCode, 200) 84 | const pattern2 = String.raw`sessionId=[\w-]{32}.[\w-%]{43,57}; Path=\/; HttpOnly; Secure` 85 | t.assert.strictEqual(new RegExp(pattern2).test(response2.headers['set-cookie']), true) 86 | }) 87 | 88 | test('should support multiple secrets', async (t) => { 89 | t.plan(10) 90 | const sign = require('@fastify/cookie/signer').sign 91 | 92 | const newSecret = 'geheim' 93 | 94 | const sessionId = 'aYb4uTIhdBXCfk_ylik4QN6-u26K0u0e' 95 | const sessionIdSignedWithOldSecret = sign(sessionId, DEFAULT_SECRET) 96 | const sessionIdSignedWithNewSecret = sign(sessionId, newSecret) 97 | 98 | const storeMap = new Map() 99 | const store = new TestStore(storeMap) 100 | 101 | storeMap.set(sessionId, { 102 | test: 0, 103 | cookie: {} 104 | }) 105 | 106 | const options = { 107 | secret: [newSecret, DEFAULT_SECRET], 108 | store 109 | } 110 | 111 | let counter = 0 112 | const fastify = await buildFastify( 113 | async function handler (request, reply) { 114 | t.assert.strictEqual(request.session.sessionId, sessionId) 115 | t.assert.strictEqual(request.session.test, counter) 116 | 117 | request.session.test = ++counter 118 | await request.session.save() 119 | reply.send(200) 120 | }, options) 121 | 122 | t.after(() => fastify.close()) 123 | 124 | const response1 = await fastify.inject({ 125 | url: '/', 126 | headers: { 127 | 'x-forwarded-proto': 'https', 128 | cookie: `sessionId=${sessionIdSignedWithOldSecret}; Path=/; HttpOnly; Secure` 129 | } 130 | }) 131 | t.assert.strictEqual(response1.statusCode, 200) 132 | t.assert.ok(response1.headers['set-cookie'].includes(encodeURIComponent(sessionIdSignedWithNewSecret))) 133 | 134 | const response2 = await fastify.inject({ 135 | url: '/', 136 | headers: { 137 | 'x-forwarded-proto': 'https', 138 | cookie: `sessionId=${sessionIdSignedWithNewSecret}; Path=/; HttpOnly; Secure` 139 | } 140 | }) 141 | t.assert.strictEqual(storeMap.get(sessionId).sessionId, undefined) 142 | t.assert.strictEqual(storeMap.get(sessionId).test, 2) 143 | t.assert.strictEqual(response2.statusCode, 200) 144 | t.assert.strictEqual(response2.headers['set-cookie'].includes(sessionId), true) 145 | }) 146 | 147 | test('should set session cookie using the specified cookie name', async (t) => { 148 | t.plan(2) 149 | const options = { 150 | secret: DEFAULT_SECRET, 151 | cookieName: 'anothername' 152 | } 153 | const fastify = await buildFastify((request, reply) => { 154 | request.session.test = {} 155 | reply.send(200) 156 | }, options) 157 | t.after(() => fastify.close()) 158 | 159 | const response = await fastify.inject({ 160 | url: '/', 161 | headers: { 'x-forwarded-proto': 'https' } 162 | }) 163 | 164 | t.assert.strictEqual(response.statusCode, 200) 165 | const pattern = String.raw`anothername=[\w-]{32}.[\w-%]{43,57}; Path=\/; HttpOnly; Secure` 166 | t.assert.strictEqual(new RegExp(pattern).test(response.headers['set-cookie']), true) 167 | }) 168 | 169 | test('should set session cookie using the default cookie name', async (t) => { 170 | t.plan(2) 171 | function handler (request, reply) { 172 | request.session.test = {} 173 | reply.send(200) 174 | } 175 | const fastify = await buildFastify(handler, DEFAULT_OPTIONS) 176 | t.after(() => fastify.close()) 177 | 178 | const response = await fastify.inject({ 179 | url: '/', 180 | headers: { 181 | cookie: DEFAULT_COOKIE, 182 | 'x-forwarded-proto': 'https' 183 | } 184 | }) 185 | 186 | t.assert.strictEqual(response.statusCode, 200) 187 | const pattern = String.raw`sessionId=[\w-]{32}.[\w-%]{43,57}; Path=\/; HttpOnly; Secure` 188 | t.assert.strictEqual(new RegExp(pattern).test(response.headers['set-cookie']), true) 189 | }) 190 | 191 | test('should set express sessions using the specified cookiePrefix', async (t) => { 192 | t.plan(2) 193 | const options = { 194 | secret: 'cNaoPYAwF60HZJzkcNaoPYAwF60HZJzk', 195 | cookieName: 'connect.sid', 196 | cookiePrefix: 's:' 197 | } 198 | 199 | const plugin = fastifyPlugin(async (fastify) => { 200 | fastify.addHook('onRequest', (request, _reply, done) => { 201 | request.sessionStore.set('Qk_XT2K7-clT-x1tVvoY6tIQ83iP72KN', { 202 | expires: new Date(Date.now() + 1000) 203 | }, done) 204 | }) 205 | }) 206 | function handler (request, reply) { 207 | request.session.test = {} 208 | reply.send(200) 209 | } 210 | const fastify = await buildFastify(handler, options, plugin) 211 | t.after(() => fastify.close()) 212 | 213 | const response = await fastify.inject({ 214 | url: '/', 215 | headers: { 216 | cookie: 'connect.sid=s%3AQk_XT2K7-clT-x1tVvoY6tIQ83iP72KN.B7fUDYXU9fXF9pNuL3qm4NVmSduLJ6kzCOPh5JhHGoE; Path=/; HttpOnly; Secure', 217 | 'x-forwarded-proto': 'https' 218 | } 219 | }) 220 | 221 | t.assert.strictEqual(response.statusCode, 200) 222 | const pattern = String.raw`connect.sid=s%3A[\w-]{32}.[\w-%]{43,57}; Path=\/; HttpOnly; Secure` 223 | t.assert.strictEqual(new RegExp(pattern).test(response.headers['set-cookie']), true) 224 | }) 225 | 226 | test('should create new session on expired session', async (t) => { 227 | t.plan(3) 228 | 229 | const DateNow = Date.now 230 | const now = Date.now() 231 | Date.now = () => now 232 | const plugin = fastifyPlugin(async (fastify) => { 233 | fastify.addHook('onRequest', (request, _reply, done) => { 234 | request.sessionStore.set(DEFAULT_SESSION_ID, { 235 | cookie: { secure: true, httpOnly: true, path: '/', expires: new Date(Date.now() - 1000) } 236 | }, done) 237 | }) 238 | }) 239 | function handler (_request, reply) { 240 | reply.send(200) 241 | } 242 | const options = { 243 | secret: DEFAULT_SECRET, 244 | cookie: { maxAge: 100 } 245 | } 246 | const fastify = await buildFastify(handler, options, plugin) 247 | t.after(() => { 248 | fastify.close() 249 | Date.now = DateNow 250 | }) 251 | 252 | const response = await fastify.inject({ 253 | url: '/', 254 | headers: { 255 | cookie: DEFAULT_COOKIE, 256 | 'x-forwarded-proto': 'https' 257 | } 258 | }) 259 | 260 | t.assert.strictEqual(response.statusCode, 200) 261 | t.assert.strictEqual(response.headers['set-cookie'].includes(DEFAULT_SESSION_ID), false) 262 | t.assert.strictEqual(new RegExp(`sessionId=.*; Path=/; Expires=${new Date(now + 100).toUTCString()}; HttpOnly; Secure`).test(response.headers['set-cookie']), true) 263 | }) 264 | 265 | test('should set session.cookie.expires if maxAge', async (t) => { 266 | t.plan(3) 267 | const options = { 268 | secret: DEFAULT_SECRET, 269 | cookie: { maxAge: 42 } 270 | } 271 | function handler (request, reply) { 272 | t.assert.ok(request.session.cookie.expires) 273 | reply.send(200) 274 | } 275 | const fastify = await buildFastify(handler, options) 276 | t.after(() => fastify.close()) 277 | 278 | const response = await fastify.inject({ 279 | url: '/', 280 | headers: { cookie: DEFAULT_COOKIE, 'x-forwarded-proto': 'https' } 281 | }) 282 | 283 | t.assert.strictEqual(response.statusCode, 200) 284 | const pattern = String.raw`sessionId=.*\..*; Path=\/; Expires=.*; HttpOnly; Secure` 285 | t.assert.strictEqual(new RegExp(pattern).test(response.headers['set-cookie']), true) 286 | }) 287 | 288 | test('should set new session cookie if expired', async (t) => { 289 | t.plan(3) 290 | 291 | const plugin = fastifyPlugin(async (fastify) => { 292 | fastify.addHook('onRequest', (request, _reply, done) => { 293 | request.sessionStore.set(DEFAULT_SESSION_ID, { 294 | cookie: { 295 | expires: new Date(Date.now() - 1000) 296 | } 297 | }, done) 298 | }) 299 | await sleep() 300 | }) 301 | function handler (request, reply) { 302 | request.session.test = {} 303 | reply.send(200) 304 | } 305 | const fastify = await buildFastify(handler, DEFAULT_OPTIONS, plugin) 306 | t.after(() => fastify.close()) 307 | 308 | const response = await fastify.inject({ 309 | url: '/', 310 | headers: { 311 | cookie: DEFAULT_COOKIE, 312 | 'x-forwarded-proto': 'https' 313 | } 314 | }) 315 | 316 | t.assert.strictEqual(response.headers['set-cookie'].includes(DEFAULT_ENCRYPTED_SESSION_ID), false) 317 | t.assert.strictEqual(response.statusCode, 200) 318 | const pattern = String.raw`sessionId=[\w-]{32}.[\w-%]{43,57}; Path=\/; HttpOnly; Secure` 319 | t.assert.strictEqual(new RegExp(pattern).test(response.headers['set-cookie']), true) 320 | }) 321 | 322 | test('should return new session cookie if does not exist in store', async (t) => { 323 | t.plan(3) 324 | const options = { secret: DEFAULT_SECRET } 325 | const fastify = await buildFastify((request, reply) => { 326 | request.session.test = {} 327 | reply.send(200) 328 | }, options) 329 | t.after(() => fastify.close()) 330 | 331 | const response = await fastify.inject({ 332 | url: '/', 333 | headers: { 334 | cookie: DEFAULT_COOKIE, 335 | 'x-forwarded-proto': 'https' 336 | } 337 | }) 338 | 339 | t.assert.strictEqual(response.statusCode, 200) 340 | t.assert.strictEqual(response.headers['set-cookie'].includes(DEFAULT_ENCRYPTED_SESSION_ID), false) 341 | const pattern = String.raw`sessionId=[\w-]{32}.[\w-%]{43,57}; Path=\/; HttpOnly; Secure` 342 | t.assert.strictEqual(new RegExp(pattern).test(response.headers['set-cookie']), true) 343 | }) 344 | 345 | test('should not set session cookie on invalid path', async (t) => { 346 | t.plan(2) 347 | const options = { 348 | secret: DEFAULT_SECRET, 349 | cookie: { path: '/path/' } 350 | } 351 | const fastify = await buildFastify((_request, reply) => reply.send(200), options) 352 | t.after(() => fastify.close()) 353 | 354 | const response = await fastify.inject({ 355 | url: '/', 356 | headers: { 'x-forwarded-proto': 'https' } 357 | }) 358 | 359 | t.assert.strictEqual(response.statusCode, 200) 360 | t.assert.ok(response.headers['set-cookie'] === undefined) 361 | }) 362 | 363 | test('should create new session if cookie contains invalid session', async (t) => { 364 | t.plan(3) 365 | const options = { secret: DEFAULT_SECRET } 366 | function handler (request, reply) { 367 | request.session.test = {} 368 | reply.send(200) 369 | } 370 | const plugin = fastifyPlugin(async (fastify) => { 371 | fastify.addHook('onRequest', (request, _reply, done) => { 372 | request.sessionStore.set(DEFAULT_SESSION_ID, { 373 | test: {} 374 | }, done) 375 | }) 376 | }) 377 | const fastify = await buildFastify(handler, options, plugin) 378 | t.after(() => fastify.close()) 379 | 380 | const response = await fastify.inject({ 381 | url: '/', 382 | headers: { 383 | cookie: `sessionId=${DEFAULT_SECRET}.badinvalidsignaturenoooo; Path=/; HttpOnly; Secure`, 384 | 'x-forwarded-proto': 'https' 385 | } 386 | }) 387 | 388 | t.assert.strictEqual(response.statusCode, 200) 389 | t.assert.strictEqual(response.headers['set-cookie'].includes('badinvalidsignaturenoooo'), false) 390 | const pattern = String.raw`sessionId=[\w-]{32}.[\w-%]{43,57}; Path=\/; HttpOnly; Secure` 391 | t.assert.strictEqual(new RegExp(pattern).test(response.headers['set-cookie']), true) 392 | }) 393 | 394 | test('should not set session cookie if no data in session and saveUninitialized is false', async (t) => { 395 | t.plan(2) 396 | const options = { 397 | secret: DEFAULT_SECRET, 398 | saveUninitialized: false 399 | } 400 | const fastify = await buildFastify((_request, reply) => reply.send(200), options) 401 | t.after(() => fastify.close()) 402 | 403 | const response = await fastify.inject({ 404 | url: '/', 405 | headers: { 'x-forwarded-proto': 'https' } 406 | }) 407 | 408 | t.assert.strictEqual(response.statusCode, 200) 409 | t.assert.ok(response.headers['set-cookie'] === undefined) 410 | }) 411 | 412 | test('should handle algorithm sha256', async (t) => { 413 | t.plan(3) 414 | const options = { secret: DEFAULT_SECRET, algorithm: 'sha256' } 415 | const fastify = await buildFastify((_request, reply) => { 416 | reply.send(200) 417 | }, options) 418 | t.after(() => fastify.close()) 419 | 420 | const response = await fastify.inject({ 421 | url: '/', 422 | headers: { 423 | cookie: DEFAULT_COOKIE, 424 | 'x-forwarded-proto': 'https' 425 | } 426 | }) 427 | 428 | t.assert.strictEqual(response.statusCode, 200) 429 | t.assert.strictEqual(response.headers['set-cookie'].includes(DEFAULT_ENCRYPTED_SESSION_ID), false) 430 | const pattern = String.raw`sessionId=[\w-]{32}.[\w-%]{43,57}; Path=\/; HttpOnly; Secure` 431 | t.assert.strictEqual(new RegExp(pattern).test(response.headers['set-cookie']), true) 432 | }) 433 | 434 | test('should handle algorithm sha512', async (t) => { 435 | t.plan(3) 436 | const options = { secret: DEFAULT_SECRET, algorithm: 'sha512' } 437 | const fastify = await buildFastify((_request, reply) => { 438 | reply.send(200) 439 | }, options) 440 | t.after(() => fastify.close()) 441 | 442 | const response = await fastify.inject({ 443 | url: '/', 444 | headers: { 445 | cookie: DEFAULT_COOKIE, 446 | 'x-forwarded-proto': 'https' 447 | } 448 | }) 449 | 450 | t.assert.strictEqual(response.statusCode, 200) 451 | t.assert.strictEqual(response.headers['set-cookie'].includes(DEFAULT_ENCRYPTED_SESSION_ID), false) 452 | const pattern = String.raw`sessionId=[\w-]{32}.[\w-%]{43,135}; Path=\/; HttpOnly; Secure` 453 | t.assert.strictEqual(new RegExp(pattern).test(response.headers['set-cookie']), true) 454 | }) 455 | 456 | test('should handle custom signer', async (t) => { 457 | const signer = new Signer(DEFAULT_SECRET, 'sha512') 458 | t.plan(3) 459 | const options = { secret: signer } 460 | const fastify = await buildFastify((_request, reply) => { 461 | reply.send(200) 462 | }, options) 463 | t.after(() => fastify.close()) 464 | 465 | const response = await fastify.inject({ 466 | url: '/', 467 | headers: { 468 | cookie: DEFAULT_COOKIE, 469 | 'x-forwarded-proto': 'https' 470 | } 471 | }) 472 | 473 | t.assert.strictEqual(response.statusCode, 200) 474 | t.assert.strictEqual(response.headers['set-cookie'].includes(DEFAULT_ENCRYPTED_SESSION_ID), false) 475 | const pattern = String.raw`sessionId=[\w-]{32}.[\w-%]{43,135}; Path=\/; HttpOnly; Secure` 476 | t.assert.strictEqual(RegExp(pattern).test(response.headers['set-cookie']), true) 477 | }) 478 | -------------------------------------------------------------------------------- /test/cookie.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const test = require('node:test') 4 | const Fastify = require('fastify') 5 | const fastifyCookie = require('@fastify/cookie') 6 | const fastifySession = require('../lib/fastifySession') 7 | const fastifyPlugin = require('fastify-plugin') 8 | const Cookie = require('../lib/cookie') 9 | const { DEFAULT_OPTIONS, DEFAULT_COOKIE, DEFAULT_SECRET, buildFastify, DEFAULT_SESSION_ID } = require('./util') 10 | 11 | test('should set session cookie', async (t) => { 12 | t.plan(2) 13 | const fastify = Fastify() 14 | 15 | fastify.addHook('onRequest', async (request) => { 16 | request.raw.socket.encrypted = true 17 | }) 18 | fastify.register(fastifyCookie) 19 | fastify.register(fastifySession, DEFAULT_OPTIONS) 20 | fastify.get('/', (request, reply) => { 21 | request.session.test = {} 22 | reply.send(200) 23 | }) 24 | await fastify.listen({ port: 0 }) 25 | t.after(() => { fastify.close() }) 26 | 27 | const response = await fastify.inject({ 28 | url: '/' 29 | }) 30 | 31 | t.assert.strictEqual(response.statusCode, 200) 32 | const pattern = String.raw`sessionId=[\w-]{32}.[\w-%]{43,57}; Path=\/; HttpOnly; Secure` 33 | t.assert.strictEqual(new RegExp(pattern).test(response.headers['set-cookie']), true) 34 | }) 35 | 36 | test('should not set session cookie is request is not secure', async (t) => { 37 | t.plan(2) 38 | const fastify = Fastify() 39 | fastify.addHook('onRequest', async (request) => { 40 | request.raw.socket.encrypted = false 41 | }) 42 | fastify.register(fastifyCookie) 43 | fastify.register(fastifySession, DEFAULT_OPTIONS) 44 | fastify.get('/', (_request, reply) => reply.send(200)) 45 | await fastify.listen({ port: 0 }) 46 | t.after(() => { fastify.close() }) 47 | 48 | const response = await fastify.inject({ 49 | url: '/' 50 | }) 51 | 52 | t.assert.strictEqual(response.statusCode, 200) 53 | t.assert.strictEqual(response.headers['set-cookie'], undefined) 54 | }) 55 | 56 | test('should not set session cookie is request is not secure and x-forwarded-proto != https', async (t) => { 57 | t.plan(2) 58 | const fastify = Fastify({ trustProxy: true }) 59 | fastify.addHook('onRequest', async (request) => { 60 | request.raw.socket.encrypted = false 61 | }) 62 | fastify.register(fastifyCookie) 63 | fastify.register(fastifySession, DEFAULT_OPTIONS) 64 | fastify.get('/', (_request, reply) => reply.send(200)) 65 | await fastify.listen({ port: 0 }) 66 | t.after(() => { fastify.close() }) 67 | 68 | const response = await fastify.inject({ 69 | url: '/', 70 | headers: { 'x-forwarded-proto': 'http' } 71 | }) 72 | 73 | t.assert.strictEqual(response.statusCode, 200) 74 | t.assert.strictEqual(response.headers['set-cookie'], undefined) 75 | }) 76 | 77 | test('should set session cookie is request is not secure and x-forwarded-proto = https', async (t) => { 78 | t.plan(2) 79 | const fastify = Fastify({ trustProxy: true }) 80 | fastify.addHook('onRequest', async (request) => { 81 | request.raw.socket.encrypted = false 82 | }) 83 | fastify.register(fastifyCookie) 84 | fastify.register(fastifySession, DEFAULT_OPTIONS) 85 | fastify.get('/', (request, reply) => { 86 | request.session.test = {} 87 | reply.send(200) 88 | }) 89 | await fastify.listen({ port: 0 }) 90 | t.after(() => { fastify.close() }) 91 | 92 | const response = await fastify.inject({ 93 | url: '/', 94 | headers: { 'x-forwarded-proto': 'https' } 95 | }) 96 | 97 | t.assert.strictEqual(response.statusCode, 200) 98 | t.assert.ok(response.headers['set-cookie']) 99 | }) 100 | 101 | test('session.cookie should have expires if maxAge is set', async (t) => { 102 | t.plan(3) 103 | const options = { 104 | secret: DEFAULT_SECRET, 105 | cookie: { maxAge: 100000000, secure: false } 106 | } 107 | const fastify = await buildFastify((request, reply) => { 108 | t.assert.strictEqual(request.session.cookie.originalMaxAge, 100000000) 109 | reply.send(200) 110 | }, options) 111 | t.after(() => { fastify.close() }) 112 | 113 | const response = await fastify.inject({ 114 | url: '/' 115 | }) 116 | 117 | t.assert.strictEqual(response.statusCode, 200) 118 | t.assert.ok(response.headers['set-cookie'].includes('Expires=')) 119 | }) 120 | 121 | test('should set session cookie with expires if maxAge', async (t) => { 122 | t.plan(2) 123 | const options = { 124 | secret: DEFAULT_SECRET, 125 | cookie: { maxAge: 42 } 126 | } 127 | const fastify = await buildFastify((request, reply) => { 128 | request.session.test = {} 129 | reply.send(200) 130 | }, options) 131 | t.after(() => { fastify.close() }) 132 | 133 | const response = await fastify.inject({ 134 | url: '/', 135 | headers: { 'x-forwarded-proto': 'https' } 136 | }) 137 | 138 | t.assert.strictEqual(response.statusCode, 200) 139 | const pattern = String.raw`sessionId=[\w-]{32}.[\w-%]{43,57}; Path=\/; Expires=[\w, :]{29}; HttpOnly; Secure` 140 | t.assert.strictEqual(new RegExp(pattern).test(response.headers['set-cookie']), true) 141 | }) 142 | 143 | test('should set session cookie with maxAge', async (t) => { 144 | t.plan(2) 145 | const options = { 146 | secret: DEFAULT_SECRET, 147 | cookie: { domain: 'localhost' } 148 | } 149 | const fastify = await buildFastify((request, reply) => { 150 | request.session.test = {} 151 | reply.send(200) 152 | }, options) 153 | t.after(() => { fastify.close() }) 154 | 155 | const response = await fastify.inject({ 156 | url: '/', 157 | headers: { 'x-forwarded-proto': 'https' } 158 | }) 159 | 160 | t.assert.strictEqual(response.statusCode, 200) 161 | const pattern = String.raw`sessionId=[\w-]{32}.[\w-%]{43,57}; Domain=localhost; Path=\/; HttpOnly; Secure` 162 | t.assert.strictEqual(new RegExp(pattern).test(response.headers['set-cookie']), true) 163 | }) 164 | 165 | test('should set session cookie with sameSite', async (t) => { 166 | t.plan(2) 167 | const options = { 168 | secret: DEFAULT_SECRET, 169 | cookie: { sameSite: true } 170 | } 171 | const fastify = await buildFastify((request, reply) => { 172 | request.session.test = {} 173 | reply.send(200) 174 | }, options) 175 | t.after(() => { fastify.close() }) 176 | 177 | const response = await fastify.inject({ 178 | url: '/', 179 | headers: { 'x-forwarded-proto': 'https' } 180 | }) 181 | 182 | t.assert.strictEqual(response.statusCode, 200) 183 | const pattern = String.raw`sessionId=[\w-]{32}.[\w-%]{43,57}; Path=\/; HttpOnly; Secure; SameSite=Strict` 184 | t.assert.strictEqual(new RegExp(pattern).test(response.headers['set-cookie']), true) 185 | }) 186 | 187 | test('should set session another path in cookie', async (t) => { 188 | t.plan(2) 189 | const fastify = Fastify({ trustProxy: true }) 190 | 191 | const options = { 192 | secret: DEFAULT_SECRET, 193 | cookie: { path: '/a/test/path' } 194 | } 195 | fastify.register(fastifyCookie) 196 | fastify.register(fastifySession, options) 197 | fastify.get('/a/test/path', (request, reply) => { 198 | request.session.test = {} 199 | reply.send(200) 200 | }) 201 | await fastify.listen({ port: 0 }) 202 | t.after(() => { fastify.close() }) 203 | 204 | const response = await fastify.inject({ 205 | url: '/a/test/path', 206 | headers: { 'x-forwarded-proto': 'https' } 207 | }) 208 | 209 | t.assert.strictEqual(response.statusCode, 200) 210 | const pattern = String.raw`sessionId=[\w-]{32}.[[\w-%]{43,57}; Path=\/a\/test\/path; HttpOnly; Secure` 211 | t.assert.strictEqual(new RegExp(pattern).test(response.headers['set-cookie']), true) 212 | }) 213 | 214 | test('should set session cookie with expires', async (t) => { 215 | t.plan(2) 216 | const date = new Date() 217 | date.setTime(34214461000) 218 | const options = { 219 | secret: DEFAULT_SECRET, 220 | cookie: { expires: date } 221 | } 222 | const fastify = await buildFastify((request, reply) => { 223 | request.session.test = {} 224 | reply.send(200) 225 | }, options) 226 | t.after(() => { fastify.close() }) 227 | 228 | const response = await fastify.inject({ 229 | url: '/', 230 | headers: { 'x-forwarded-proto': 'https' } 231 | }) 232 | 233 | t.assert.strictEqual(response.statusCode, 200) 234 | const pattern = String.raw`sessionId=[\w-]{32}.[\w-%]{43,57}; Path=\/; Expires=Mon, 01 Feb 1971 00:01:01 GMT; HttpOnly; Secure` 235 | t.assert.strictEqual(new RegExp(pattern).test(response.headers['set-cookie']), true) 236 | }) 237 | 238 | test('should set session non HttpOnly cookie', async (t) => { 239 | t.plan(2) 240 | const options = { 241 | secret: DEFAULT_SECRET, 242 | cookie: { httpOnly: false } 243 | } 244 | const fastify = await buildFastify((request, reply) => { 245 | request.session.test = {} 246 | reply.send(200) 247 | }, options) 248 | t.after(() => { fastify.close() }) 249 | 250 | const response = await fastify.inject({ 251 | url: '/', 252 | headers: { 'x-forwarded-proto': 'https' } 253 | }) 254 | 255 | t.assert.strictEqual(response.statusCode, 200) 256 | const pattern = String.raw`sessionId=[\w-]{32}.[\w-%]{43,57}; Path=\/; Secure` 257 | t.assert.strictEqual(new RegExp(pattern).test(response.headers['set-cookie']), true) 258 | }) 259 | 260 | test('should set session non secure cookie', async (t) => { 261 | t.plan(2) 262 | const options = { 263 | secret: DEFAULT_SECRET, 264 | cookie: { secure: false } 265 | } 266 | const fastify = await buildFastify((request, reply) => { 267 | request.session.test = {} 268 | reply.send(200) 269 | }, options) 270 | t.after(() => { fastify.close() }) 271 | 272 | const response = await fastify.inject({ 273 | url: '/' 274 | }) 275 | 276 | t.assert.strictEqual(response.statusCode, 200) 277 | const pattern = String.raw`sessionId=[\w-]{32}.[\w-%]{43,57}; Path=\/; HttpOnly` 278 | t.assert.strictEqual(new RegExp(pattern).test(response.headers['set-cookie']), true) 279 | }) 280 | 281 | test('should set session non secure cookie secureAuto', async (t) => { 282 | t.plan(2) 283 | const options = { 284 | secret: DEFAULT_SECRET, 285 | cookie: { secure: 'auto' } 286 | } 287 | const fastify = await buildFastify((request, reply) => { 288 | request.session.test = {} 289 | reply.send(200) 290 | }, options) 291 | t.after(() => { fastify.close() }) 292 | 293 | const response = await fastify.inject({ 294 | url: '/' 295 | }) 296 | 297 | t.assert.strictEqual(response.statusCode, 200) 298 | const pattern = String.raw`sessionId=[\w-]{32}.[\w-%]{43,57}; Path=\/; HttpOnly` 299 | t.assert.strictEqual(new RegExp(pattern).test(response.headers['set-cookie']), true) 300 | }) 301 | 302 | test('should set session cookie secureAuto', async (t) => { 303 | t.plan(2) 304 | const fastify = Fastify() 305 | fastify.addHook('onRequest', async (request) => { 306 | request.raw.socket.encrypted = false 307 | }) 308 | fastify.register(fastifyCookie) 309 | fastify.register(fastifySession, { 310 | secret: DEFAULT_SECRET, 311 | cookie: { secure: 'auto' } 312 | }) 313 | fastify.get('/', (request, reply) => { 314 | request.session.test = {} 315 | reply.send(200) 316 | }) 317 | await fastify.listen({ port: 0 }) 318 | t.after(() => { fastify.close() }) 319 | 320 | const response = await fastify.inject({ 321 | url: '/' 322 | }) 323 | 324 | t.assert.strictEqual(response.statusCode, 200) 325 | const pattern = String.raw`sessionId=[\w-]{32}.[\w-%]{43,57}; Path=\/; HttpOnly; SameSite=Lax` 326 | t.assert.strictEqual(new RegExp(pattern).test(response.headers['set-cookie']), true) 327 | }) 328 | 329 | test('should set session cookie secureAuto change SameSite', async (t) => { 330 | t.plan(2) 331 | const fastify = Fastify() 332 | fastify.addHook('onRequest', async (request) => { 333 | request.raw.socket.encrypted = false 334 | }) 335 | fastify.register(fastifyCookie) 336 | fastify.register(fastifySession, { 337 | secret: DEFAULT_SECRET, 338 | cookie: { secure: 'auto', sameSite: 'none' } 339 | }) 340 | fastify.get('/', (request, reply) => { 341 | request.session.test = {} 342 | reply.send(200) 343 | }) 344 | await fastify.listen({ port: 0 }) 345 | t.after(() => { fastify.close() }) 346 | 347 | const response = await fastify.inject({ 348 | url: '/' 349 | }) 350 | 351 | t.assert.strictEqual(response.statusCode, 200) 352 | const pattern = String.raw`sessionId=[\w-]{32}.[\w-%]{43,57}; Path=\/; HttpOnly; SameSite=Lax` 353 | t.assert.strictEqual(new RegExp(pattern).test(response.headers['set-cookie']), true) 354 | }) 355 | 356 | test('should set session cookie secureAuto keep SameSite when secured', async (t) => { 357 | t.plan(2) 358 | const fastify = Fastify() 359 | fastify.addHook('onRequest', async (request) => { 360 | request.raw.socket.encrypted = true 361 | }) 362 | fastify.register(fastifyCookie) 363 | fastify.register(fastifySession, { 364 | secret: DEFAULT_SECRET, 365 | cookie: { secure: 'auto', sameSite: 'none' } 366 | }) 367 | fastify.get('/', (request, reply) => { 368 | request.session.test = {} 369 | reply.send(200) 370 | }) 371 | await fastify.listen({ port: 0 }) 372 | t.after(() => { fastify.close() }) 373 | 374 | const response = await fastify.inject({ 375 | url: '/' 376 | }) 377 | 378 | t.assert.strictEqual(response.statusCode, 200) 379 | const pattern = String.raw`sessionId=[\w-]{32}.[\w-%]{43,57}; Path=\/; HttpOnly; Secure; SameSite=None` 380 | t.assert.strictEqual(new RegExp(pattern).test(response.headers['set-cookie']), true) 381 | }) 382 | 383 | test('should set session secure cookie secureAuto http encrypted', async (t) => { 384 | t.plan(2) 385 | const fastify = Fastify() 386 | fastify.addHook('onRequest', async (request) => { 387 | request.raw.socket.encrypted = true 388 | }) 389 | fastify.register(fastifyCookie) 390 | fastify.register(fastifySession, { 391 | secret: DEFAULT_SECRET, 392 | cookie: { secure: 'auto' } 393 | }) 394 | fastify.get('/', (request, reply) => { 395 | request.session.test = {} 396 | reply.send(200) 397 | }) 398 | await fastify.listen({ port: 0 }) 399 | t.after(() => { fastify.close() }) 400 | 401 | const response = await fastify.inject({ 402 | url: '/' 403 | }) 404 | 405 | t.assert.strictEqual(response.statusCode, 200) 406 | const pattern = String.raw`sessionId=[\w-]{32}.[\w-%]{43,57}; Path=\/; HttpOnly; Secure` 407 | t.assert.strictEqual(new RegExp(pattern).test(response.headers['set-cookie']), true) 408 | }) 409 | 410 | test('should set session secure cookie secureAuto x-forwarded-proto header', async (t) => { 411 | t.plan(2) 412 | const options = { 413 | secret: DEFAULT_SECRET, 414 | cookie: { secure: 'auto' } 415 | } 416 | const fastify = await buildFastify((request, reply) => { 417 | request.session.test = {} 418 | reply.send(200) 419 | }, options) 420 | t.after(() => { fastify.close() }) 421 | 422 | const response = await fastify.inject({ 423 | url: '/', 424 | headers: { 'x-forwarded-proto': 'https' } 425 | }) 426 | 427 | t.assert.strictEqual(response.statusCode, 200) 428 | const pattern = String.raw`sessionId=[\w-]{32}.[\w-%]{43,57}; Path=\/; HttpOnly; Secure` 429 | t.assert.strictEqual(new RegExp(pattern).test(response.headers['set-cookie']), true) 430 | }) 431 | 432 | test('should set session partitioned cookie secure http encrypted', async (t) => { 433 | t.plan(2) 434 | const fastify = Fastify() 435 | fastify.addHook('onRequest', async (request) => { 436 | request.raw.socket.encrypted = true 437 | }) 438 | fastify.register(fastifyCookie) 439 | fastify.register(fastifySession, { 440 | secret: DEFAULT_SECRET, 441 | cookie: { secure: 'true', partitioned: true } 442 | }) 443 | fastify.get('/', (request, reply) => { 444 | request.session.test = {} 445 | reply.send(200) 446 | }) 447 | await fastify.listen({ port: 0 }) 448 | t.after(() => { fastify.close() }) 449 | 450 | const response = await fastify.inject({ 451 | url: '/' 452 | }) 453 | 454 | t.assert.strictEqual(response.statusCode, 200) 455 | const pattern = String.raw`sessionId=[\w-]{32}.[\w-%]{43,57}; Path=\/; HttpOnly; Secure; Partitioned` 456 | t.assert.strictEqual(new RegExp(pattern).test(response.headers['set-cookie']), true) 457 | }) 458 | 459 | test('should use maxAge instead of expires in session if both are set in options.cookie', async (t) => { 460 | t.plan(3) 461 | const expires = new Date(34214461000) // 1971-02-01T00:01:01.000Z 462 | const options = { 463 | secret: DEFAULT_SECRET, 464 | cookie: { maxAge: 1000, expires } 465 | } 466 | const fastify = await buildFastify((request, reply) => { 467 | request.session.test = {} 468 | reply.code(200).send(Date.now().toString()) 469 | }, options) 470 | t.after(() => { fastify.close() }) 471 | 472 | const response = await fastify.inject({ 473 | url: '/', 474 | headers: { 'x-forwarded-proto': 'https' } 475 | }) 476 | 477 | const dateFromBody = new Date(Number(response.body)) 478 | t.assert.strictEqual(response.statusCode, 200) 479 | // Expires attribute should be determined by options.maxAge -> Date.now() + 1000 and should have the same year from response.body, 480 | // and not determined by options.expires and should not have the year of 1971 481 | const pattern1 = String.raw`sessionId=[\w-]{32}.[\w-%]{43,57}; Path=\/; Expires=\w+, \d+ \w+ 1971 \d{2}:\d{2}:\d{2} GMT; HttpOnly; Secure/` 482 | t.assert.strictEqual(new RegExp(pattern1).exec(response.headers['set-cookie']), null) 483 | const pattern2 = String.raw`sessionId=[\w-]{32}.[\w-%]{43,57}; Path=\/; Expires=\w+, \d+ \w+ ${dateFromBody.getFullYear()} \d{2}:\d{2}:\d{2} GMT; HttpOnly; Secure` 484 | t.assert.strictEqual(new RegExp(pattern2).test(response.headers['set-cookie']), true) 485 | }) 486 | 487 | test('should use session.cookie.originalMaxAge instead of the default maxAge', async (t) => { 488 | t.plan(2) 489 | 490 | const originalMaxAge = 1000 491 | const maxAge = 2000 492 | 493 | const DateNow = Date.now 494 | const now = Date.now() 495 | Date.now = () => now 496 | 497 | const plugin = fastifyPlugin(async (fastify) => { 498 | fastify.addHook('onRequest', (request, _reply, done) => { 499 | request.sessionStore.set(DEFAULT_SESSION_ID, { 500 | cookie: { 501 | originalMaxAge, 502 | expires: new Date(now + originalMaxAge) 503 | } 504 | }, done) 505 | }) 506 | }) 507 | 508 | const fastify = await buildFastify((_request, reply) => { 509 | reply.send(200) 510 | }, { secret: DEFAULT_SECRET, cookie: { maxAge } }, plugin) 511 | t.after(() => { 512 | fastify.close() 513 | Date.now = DateNow 514 | }) 515 | 516 | const response = await fastify.inject({ 517 | url: '/', 518 | headers: { cookie: DEFAULT_COOKIE, 'x-forwarded-proto': 'https' } 519 | }) 520 | 521 | t.assert.strictEqual(response.statusCode, 200) 522 | const pattern = String.raw`sessionId=.*; Path=/; Expires=${new Date(now + originalMaxAge).toUTCString()}; HttpOnly` 523 | t.assert.strictEqual(new RegExp(pattern).test(response.headers['set-cookie']), true) 524 | }) 525 | 526 | test('when cookie secure is set to false then store secure as false', async t => { 527 | t.plan(4) 528 | 529 | const fastify = Fastify() 530 | fastify.register(fastifyCookie) 531 | 532 | fastify.register(fastifySession, { 533 | ...DEFAULT_OPTIONS, 534 | saveUninitialized: true, 535 | cookie: { secure: false }, 536 | rolling: true 537 | }) 538 | 539 | fastify.get('/', (request, reply) => { 540 | t.assert.strictEqual(request.session.cookie.secure, false) 541 | reply.send(200) 542 | }) 543 | 544 | const response = await fastify.inject({ path: '/' }) 545 | 546 | t.assert.strictEqual(response.statusCode, 200) 547 | t.assert.strictEqual(typeof response.headers['set-cookie'], 'string') 548 | const pattern = String.raw`^sessionId=[\w-]{32}.[\w-%]{43,135}; Path=\/; HttpOnly$` 549 | t.assert.strictEqual(new RegExp(pattern).test(response.headers['set-cookie']), true) 550 | }) 551 | 552 | test('Cookie', async t => { 553 | t.plan(4) 554 | 555 | const cookie = new Cookie({}) 556 | 557 | await t.test('properties', t => { 558 | t.plan(10) 559 | 560 | t.assert.strictEqual('expires' in cookie, true) 561 | t.assert.strictEqual('originalMaxAge' in cookie, true) 562 | t.assert.strictEqual('sameSite' in cookie, true) 563 | t.assert.strictEqual('secure' in cookie, true) 564 | t.assert.strictEqual('path' in cookie, true) 565 | t.assert.strictEqual('httpOnly' in cookie, true) 566 | t.assert.strictEqual('domain' in cookie, true) 567 | t.assert.strictEqual('_expires' in cookie, true) 568 | t.assert.strictEqual('maxAge' in cookie, true) 569 | t.assert.strictEqual('partitioned' in cookie, true) 570 | }) 571 | 572 | await t.test('toJSON', t => { 573 | t.plan(10) 574 | 575 | const json = cookie.toJSON() 576 | 577 | t.assert.strictEqual('expires' in json, true) 578 | t.assert.strictEqual('originalMaxAge' in json, true) 579 | t.assert.strictEqual('sameSite' in json, true) 580 | t.assert.strictEqual('secure' in json, true) 581 | t.assert.strictEqual('path' in json, true) 582 | t.assert.strictEqual('httpOnly' in json, true) 583 | t.assert.strictEqual('domain' in json, true) 584 | t.assert.strictEqual('partitioned' in json, true) 585 | 586 | t.assert.strictEqual('_expires' in json, false) 587 | t.assert.strictEqual('maxAge' in json, false) 588 | }) 589 | 590 | // the clock ticks while we're testing, so to prevent occasional test 591 | // failures where these tests tick over 1ms, take the time difference into 592 | // account: 593 | 594 | await t.test('maxAge calculated from expires', t => { 595 | t.plan(2) 596 | 597 | const startT = Date.now() 598 | const cookie = new Cookie({ expires: new Date(Date.now() + 1000) }) 599 | const maxAge = cookie.maxAge 600 | const duration = Date.now() - startT 601 | 602 | t.assert.strictEqual(maxAge <= 1000 && maxAge >= 1000 - duration, true) 603 | t.assert.strictEqual(cookie.originalMaxAge, null) 604 | }) 605 | 606 | await t.test('maxAge set by maxAge', t => { 607 | t.plan(2) 608 | 609 | const startT = Date.now() 610 | const cookie = new Cookie({ maxAge: 1000 }) 611 | const maxAge = cookie.maxAge 612 | const duration = Date.now() - startT 613 | 614 | t.assert.strictEqual(maxAge <= 1000 && maxAge >= 1000 - duration, true) 615 | t.assert.strictEqual(cookie.originalMaxAge, 1000) 616 | }) 617 | }) 618 | -------------------------------------------------------------------------------- /test/expiration.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const test = require('node:test') 4 | const { buildFastify, DEFAULT_SECRET } = require('./util') 5 | const { setTimeout: sleep } = require('node:timers/promises') 6 | 7 | test('sessions should be deleted if expired', async (t) => { 8 | t.plan(5) 9 | 10 | const sessions = {} 11 | const options = { 12 | secret: DEFAULT_SECRET, 13 | store: { 14 | get (id, cb) { 15 | t.assert.ok(id) 16 | cb(null, sessions[id]) 17 | }, 18 | set (id, session, cb) { 19 | sessions[id] = session 20 | cb() 21 | }, 22 | destroy (id, cb) { 23 | t.assert.ok(id) 24 | cb() 25 | } 26 | }, 27 | cookie: { maxAge: 1000, secure: false } 28 | } 29 | 30 | const fastify = await buildFastify((_request, reply) => { 31 | reply.send(200) 32 | }, options) 33 | t.after(() => { 34 | fastify.close() 35 | }) 36 | 37 | let response 38 | response = await fastify.inject({ 39 | url: '/' 40 | }) 41 | 42 | const initialSession = response.headers['set-cookie'] 43 | .split(' ')[0] 44 | .replace(';', '') 45 | t.assert.ok(initialSession.startsWith('sessionId=')) 46 | 47 | // Wait for the cookie to expire 48 | await sleep(2000) 49 | 50 | response = await fastify.inject({ 51 | url: '/', 52 | headers: { 53 | Cookie: initialSession 54 | } 55 | }) 56 | 57 | const endingSession = response.headers['set-cookie'] 58 | .split(' ')[0] 59 | .replace(';', '') 60 | t.assert.ok(endingSession.startsWith('sessionId=')) 61 | 62 | t.assert.notEqual(initialSession, endingSession) 63 | }) 64 | -------------------------------------------------------------------------------- /test/fastifySession.checkOptions.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const test = require('node:test') 4 | const Fastify = require('fastify') 5 | const fastifyCookie = require('@fastify/cookie') 6 | const fastifySession = require('..') 7 | const crypto = require('node:crypto') 8 | 9 | test('fastifySession.checkOptions: register should fail if no secret is specified', async t => { 10 | t.plan(2) 11 | const fastify = Fastify() 12 | 13 | const options = {} 14 | fastify.register(fastifyCookie) 15 | fastify.register(fastifySession, options) 16 | 17 | await t.assert.rejects( 18 | fastify.ready(), 19 | (err) => { 20 | t.assert.strictEqual(err.message, 'the secret option is required, and must be a String, Array of Strings, or a signer object with .sign and .unsign methods') 21 | return true 22 | } 23 | ) 24 | }) 25 | 26 | test('fastifySession.checkOptions: register should succeed if secret with 32 characters is specified', async t => { 27 | t.plan(2) 28 | const fastify = Fastify() 29 | 30 | fastify.register(fastifyCookie) 31 | 32 | const secret = crypto.randomBytes(16).toString('hex') 33 | t.assert.strictEqual(secret.length, 32) 34 | fastify.register(fastifySession, { secret }) 35 | await t.assert.doesNotReject(fastify.ready()) 36 | }) 37 | 38 | test('fastifySession.checkOptions: register should fail if the secret is too short', async t => { 39 | t.plan(3) 40 | const fastify = Fastify() 41 | 42 | const secret = crypto.randomBytes(16).toString('hex').slice(0, 31) 43 | t.assert.strictEqual(secret.length, 31) 44 | fastify.register(fastifyCookie) 45 | fastify.register(fastifySession, { secret }) 46 | await t.assert.rejects( 47 | fastify.ready(), 48 | (err) => { 49 | t.assert.strictEqual(err.message, 'the secret must have length 32 or greater') 50 | return true 51 | } 52 | ) 53 | }) 54 | 55 | test('fastifySession.checkOptions: register should succeed if secret is short, but in an array', async t => { 56 | t.plan(2) 57 | const fastify = Fastify() 58 | 59 | const secret = crypto.randomBytes(16).toString('hex').slice(0, 31) 60 | t.assert.strictEqual(secret.length, 31) 61 | fastify.register(fastifyCookie) 62 | fastify.register(fastifySession, { secret: [secret] }) 63 | await t.assert.doesNotReject(fastify.ready()) 64 | }) 65 | 66 | test('fastifySession.checkOptions: register should succeed if multiple secrets are present', async t => { 67 | t.plan(1) 68 | const fastify = Fastify() 69 | 70 | fastify.register(fastifyCookie) 71 | fastify.register(fastifySession, { 72 | secret: [ 73 | crypto.randomBytes(16).toString('hex'), 74 | crypto.randomBytes(15).toString('hex') 75 | ] 76 | }) 77 | await t.assert.doesNotReject(fastify.ready()) 78 | }) 79 | 80 | test('fastifySession.checkOptions: register should fail if no secret is present in array', async t => { 81 | t.plan(2) 82 | const fastify = Fastify() 83 | 84 | fastify.register(fastifyCookie) 85 | fastify.register(fastifySession, { secret: [] }) 86 | await t.assert.rejects( 87 | fastify.ready(), 88 | (err) => { 89 | t.assert.strictEqual(err.message, 'at least one secret is required') 90 | return true 91 | } 92 | ) 93 | }) 94 | 95 | test('fastifySession.checkOptions: register should fail if a Buffer is passed', async t => { 96 | t.plan(2) 97 | const fastify = Fastify() 98 | 99 | fastify.register(fastifyCookie) 100 | fastify.register(fastifySession, { secret: crypto.randomBytes(32) }) 101 | await t.assert.rejects( 102 | fastify.ready(), 103 | (err) => { 104 | t.assert.strictEqual(err.message, 'the secret option is required, and must be a String, Array of Strings, or a signer object with .sign and .unsign methods') 105 | return true 106 | } 107 | ) 108 | }) 109 | 110 | test('fastifySession.checkOptions: register should fail if a signer missing unsign is passed', async t => { 111 | t.plan(2) 112 | const fastify = Fastify() 113 | 114 | const invalidSigner = { 115 | sign: (x) => x, 116 | unsign: true 117 | } 118 | 119 | fastify.register(fastifyCookie) 120 | fastify.register(fastifySession, { secret: invalidSigner }) 121 | await t.assert.rejects( 122 | fastify.ready(), 123 | (err) => { 124 | t.assert.strictEqual(err.message, 'the secret option is required, and must be a String, Array of Strings, or a signer object with .sign and .unsign methods') 125 | return true 126 | } 127 | ) 128 | }) 129 | 130 | test('fastifySession.checkOptions: register should fail if a signer missing sign is passed', async t => { 131 | t.plan(2) 132 | const fastify = Fastify() 133 | 134 | const invalidSigner = { 135 | unsign: () => true 136 | } 137 | 138 | fastify.register(fastifyCookie) 139 | fastify.register(fastifySession, { secret: invalidSigner }) 140 | await t.assert.rejects( 141 | fastify.ready(), 142 | (err) => { 143 | t.assert.strictEqual(err.message, 'the secret option is required, and must be a String, Array of Strings, or a signer object with .sign and .unsign methods') 144 | return true 145 | } 146 | ) 147 | }) 148 | -------------------------------------------------------------------------------- /test/idGenerator.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const test = require('node:test') 4 | const idGenerator = require('../lib/idGenerator') 5 | 6 | const cacheSize = 1 << 7 + 1 7 | 8 | if (Buffer.isEncoding('base64url')) { 9 | test('should have no collisions, base64url', async (t) => { 10 | const idGen = idGenerator(true) 11 | t.plan(cacheSize) 12 | const ids = new Set() 13 | 14 | for (let i = 0; i < (cacheSize); ++i) { 15 | const id = idGen() 16 | if (ids.has(id)) { 17 | t.assert.ifError('had a collision') 18 | } 19 | t.assert.strictEqual(id.length, 32) 20 | } 21 | }) 22 | } 23 | 24 | test('should have no collisions, base64', async (t) => { 25 | const idGen = idGenerator(false) 26 | t.plan(cacheSize) 27 | const ids = new Set() 28 | 29 | for (let i = 0; i < (cacheSize); ++i) { 30 | const id = idGen() 31 | if (ids.has(id)) { 32 | t.assert.ifError('had a collision') 33 | } 34 | t.assert.strictEqual(id.length, 32) 35 | } 36 | }) 37 | -------------------------------------------------------------------------------- /test/memorystore.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const test = require('node:test') 4 | const { MemoryStore } = require('../lib/store') 5 | const { EventEmitter } = require('node:stream') 6 | 7 | test('MemoryStore.constructor: created MemoryStore should be an EventEmitter', (t) => { 8 | t.plan(2) 9 | 10 | const store = new MemoryStore() 11 | 12 | t.assert.ok(store instanceof EventEmitter) 13 | store.on('test', () => t.assert.ok(true)) 14 | store.emit('test') 15 | }) 16 | 17 | test('MemoryStore.constructor: should accept a Map as internal store', t => { 18 | t.plan(1) 19 | 20 | const internalStore = new Map() 21 | 22 | const store = new MemoryStore(internalStore) 23 | 24 | t.assert.strictEqual(store.store, internalStore) 25 | }) 26 | 27 | test('MemoryStore.set: should successfully set a value to sessionId ', t => { 28 | t.plan(4) 29 | 30 | const internalStore = new Map() 31 | 32 | t.assert.strictEqual(internalStore.size, 0) 33 | 34 | const store = new MemoryStore(internalStore) 35 | store.set('someId', { key: 'value' }, () => { 36 | t.assert.strictEqual(internalStore.size, 1) 37 | t.assert.strictEqual(internalStore.has('someId'), true) 38 | t.assert.deepStrictEqual(internalStore.get('someId'), { key: 'value' }) 39 | }) 40 | }) 41 | 42 | test('MemoryStore.get: should successfully get a value for a valid sessionId ', t => { 43 | t.plan(1) 44 | 45 | const internalStore = new Map() 46 | internalStore.set('someId', { key: 'value' }) 47 | 48 | const store = new MemoryStore(internalStore) 49 | store.get('someId', (_err, value) => { 50 | t.assert.deepStrictEqual(value, { key: 'value' }) 51 | }) 52 | }) 53 | 54 | test('MemoryStore.get: should return undefined for an invalid sessionId ', t => { 55 | t.plan(1) 56 | 57 | const internalStore = new Map() 58 | internalStore.set('someId', { key: 'value' }) 59 | 60 | const store = new MemoryStore(internalStore) 61 | store.get('invalidId', (_err, value) => { 62 | t.assert.strictEqual(value, undefined) 63 | }) 64 | }) 65 | 66 | test('MemoryStore.destroy: should remove a sessionId / 1', t => { 67 | t.plan(2) 68 | 69 | const internalStore = new Map() 70 | internalStore.set('someId', { key: 'value' }) 71 | internalStore.set('anotherId', { key: 'value' }) 72 | 73 | const store = new MemoryStore(internalStore) 74 | store.destroy('someId', () => { 75 | t.assert.strictEqual(internalStore.size, 1) 76 | t.assert.ok(internalStore.has('anotherId')) 77 | }) 78 | }) 79 | 80 | test('MemoryStore.destroy: should remove a sessionId / 2', t => { 81 | t.plan(2) 82 | 83 | const internalStore = new Map() 84 | internalStore.set('someId', { key: 'value' }) 85 | internalStore.set('anotherId', { key: 'value' }) 86 | 87 | const store = new MemoryStore(internalStore) 88 | store.destroy('anotherId', () => { 89 | t.assert.strictEqual(internalStore.size, 1) 90 | t.assert.ok(internalStore.has('someId')) 91 | }) 92 | }) 93 | 94 | test('MemoryStore.destroy: should not remove stored sessions if invalidId is provided', t => { 95 | t.plan(3) 96 | 97 | const internalStore = new Map() 98 | internalStore.set('someId', { key: 'value' }) 99 | internalStore.set('anotherId', { key: 'value' }) 100 | 101 | const store = new MemoryStore(internalStore) 102 | store.destroy('invalidId', () => { 103 | t.assert.strictEqual(internalStore.size, 2) 104 | t.assert.ok(internalStore.has('someId')) 105 | t.assert.ok(internalStore.has('anotherId')) 106 | }) 107 | }) 108 | -------------------------------------------------------------------------------- /test/session.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const test = require('node:test') 4 | const Fastify = require('fastify') 5 | const fastifyCookie = require('@fastify/cookie') 6 | const fastifySession = require('..') 7 | const { buildFastify, DEFAULT_OPTIONS, DEFAULT_COOKIE, DEFAULT_SESSION_ID, DEFAULT_SECRET, DEFAULT_COOKIE_VALUE } = require('./util') 8 | const { setTimeout: sleep } = require('timers/promises') 9 | 10 | test('should add session object to request', async (t) => { 11 | t.plan(2) 12 | const fastify = await buildFastify((request, reply) => { 13 | t.assert.ok(request.session) 14 | reply.send(200) 15 | }, DEFAULT_OPTIONS) 16 | t.after(() => fastify.close()) 17 | 18 | const response = await fastify.inject({ 19 | url: '/' 20 | }) 21 | 22 | t.assert.strictEqual(response.statusCode, 200) 23 | }) 24 | 25 | test('should destroy the session', async (t) => { 26 | t.plan(3) 27 | const fastify = await buildFastify((request, reply) => { 28 | request.session.destroy((err) => { 29 | t.assert.ifError(err) 30 | t.assert.strictEqual(request.session, null) 31 | reply.send(200) 32 | }) 33 | }, DEFAULT_OPTIONS) 34 | t.after(() => fastify.close()) 35 | 36 | const response = await fastify.inject({ 37 | url: '/' 38 | }) 39 | 40 | t.assert.strictEqual(response.statusCode, 200) 41 | }) 42 | 43 | test('should add session.encryptedSessionId object to request', async (t) => { 44 | t.plan(2) 45 | const fastify = await buildFastify((request, reply) => { 46 | t.assert.ok(request.session.encryptedSessionId) 47 | reply.send(200) 48 | }, DEFAULT_OPTIONS) 49 | t.after(() => fastify.close()) 50 | 51 | const response = await fastify.inject({ 52 | url: '/' 53 | }) 54 | 55 | t.assert.strictEqual(response.statusCode, 200) 56 | }) 57 | 58 | test('should add session.cookie object to request', async (t) => { 59 | t.plan(2) 60 | const fastify = await buildFastify((request, reply) => { 61 | t.assert.ok(request.session.cookie) 62 | reply.send(200) 63 | }, DEFAULT_OPTIONS) 64 | t.after(() => fastify.close()) 65 | 66 | const response = await fastify.inject({ 67 | url: '/' 68 | }) 69 | 70 | t.assert.strictEqual(response.statusCode, 200) 71 | }) 72 | 73 | test('should add session.sessionId object to request', async (t) => { 74 | t.plan(2) 75 | const fastify = await buildFastify((request, reply) => { 76 | t.assert.ok(request.session.sessionId) 77 | reply.send(200) 78 | }, DEFAULT_OPTIONS) 79 | t.after(() => fastify.close()) 80 | 81 | const response = await fastify.inject({ 82 | url: '/' 83 | }) 84 | 85 | t.assert.strictEqual(response.statusCode, 200) 86 | }) 87 | 88 | test('should allow get/set methods for fetching/updating session values', async (t) => { 89 | t.plan(2) 90 | const fastify = await buildFastify((request, reply) => { 91 | request.session.set('foo', 'bar') 92 | t.assert.strictEqual(request.session.get('foo'), 'bar') 93 | reply.send(200) 94 | }, DEFAULT_OPTIONS) 95 | t.after(() => fastify.close()) 96 | 97 | const response = await fastify.inject({ 98 | url: '/' 99 | }) 100 | 101 | t.assert.strictEqual(response.statusCode, 200) 102 | }) 103 | 104 | test('should use custom sessionId generator if available (without request)', async (t) => { 105 | t.plan(2) 106 | const fastify = await buildFastify((request, reply) => { 107 | t.assert.ok(request.session.sessionId.startsWith('custom-')) 108 | reply.send(200) 109 | }, { 110 | idGenerator: () => { 111 | return `custom-${ 112 | new Date().getTime() 113 | }-${ 114 | Math.random().toString().slice(2) 115 | }` 116 | }, 117 | ...DEFAULT_OPTIONS 118 | }) 119 | t.after(() => fastify.close()) 120 | 121 | const response = await fastify.inject({ 122 | url: '/' 123 | }) 124 | 125 | t.assert.strictEqual(response.statusCode, 200) 126 | }) 127 | 128 | test('should keep user data in session throughout the time', async (t) => { 129 | t.plan(3) 130 | const fastify = Fastify() 131 | 132 | const options = { 133 | secret: DEFAULT_SECRET, 134 | cookie: { secure: false } 135 | } 136 | fastify.register(fastifyCookie) 137 | fastify.register(fastifySession, options) 138 | fastify.get('/', (request, reply) => { 139 | request.session.foo = 'bar' 140 | reply.send(200) 141 | }) 142 | fastify.get('/check', (request, reply) => { 143 | t.assert.strictEqual(request.session.foo, 'bar') 144 | reply.send(200) 145 | }) 146 | await fastify.listen({ port: 0 }) 147 | t.after(() => { fastify.close() }) 148 | 149 | const response1 = await fastify.inject({ 150 | url: '/' 151 | }) 152 | 153 | t.assert.strictEqual(response1.statusCode, 200) 154 | 155 | const response2 = await fastify.inject({ 156 | url: '/check', 157 | headers: { Cookie: response1.headers['set-cookie'] } 158 | }) 159 | 160 | t.assert.strictEqual(response2.statusCode, 200) 161 | }) 162 | 163 | test('should generate new sessionId', async (t) => { 164 | t.plan(3) 165 | const fastify = Fastify() 166 | 167 | const options = { 168 | secret: DEFAULT_SECRET, 169 | cookie: { secure: false } 170 | } 171 | let oldSessionId 172 | await fastify.register(fastifyCookie) 173 | await fastify.register(fastifySession, options) 174 | fastify.get('/', (request, reply) => { 175 | oldSessionId = request.session.sessionId 176 | request.session.regenerate(error => { 177 | if (error) { 178 | reply.status(500).send('Error ' + error) 179 | } else { 180 | reply.send(200) 181 | } 182 | }) 183 | }) 184 | fastify.get('/check', (request, reply) => { 185 | t.assert.notStrictEqual(request.session.sessionId, oldSessionId) 186 | reply.send(200) 187 | }) 188 | await fastify.listen({ port: 0 }) 189 | t.after(() => { fastify.close() }) 190 | 191 | const response1 = await fastify.inject({ 192 | url: '/' 193 | }) 194 | 195 | t.assert.strictEqual(response1.statusCode, 200) 196 | 197 | const response2 = await fastify.inject({ 198 | url: '/check', 199 | headers: { Cookie: response1.headers['set-cookie'] } 200 | }) 201 | 202 | t.assert.strictEqual(response2.statusCode, 200) 203 | }) 204 | 205 | test('should generate new sessionId keeping ignoreFields', async (t) => { 206 | t.plan(4) 207 | const fastify = Fastify() 208 | 209 | const options = { 210 | secret: DEFAULT_SECRET, 211 | cookie: { secure: false } 212 | } 213 | let oldSessionId 214 | fastify.register(fastifyCookie) 215 | fastify.register(fastifySession, options) 216 | fastify.get('/', (request, reply) => { 217 | oldSessionId = request.session.sessionId 218 | request.session.set('message', 'hello world') 219 | request.session.regenerate(['message'], error => { 220 | if (error) { 221 | reply.status(500).send('Error ' + error) 222 | } else { 223 | reply.send(200) 224 | } 225 | }) 226 | }) 227 | fastify.get('/check', (request, reply) => { 228 | t.assert.notStrictEqual(request.session.sessionId, oldSessionId) 229 | t.assert.strictEqual(request.session.get('message'), 'hello world') 230 | reply.send(200) 231 | }) 232 | await fastify.listen({ port: 0 }) 233 | t.after(() => { fastify.close() }) 234 | 235 | const response1 = await fastify.inject({ 236 | url: '/' 237 | }) 238 | 239 | t.assert.strictEqual(response1.statusCode, 200) 240 | 241 | const response2 = await fastify.inject({ 242 | url: '/check', 243 | headers: { Cookie: response1.headers['set-cookie'] } 244 | }) 245 | 246 | t.assert.strictEqual(response2.statusCode, 200) 247 | }) 248 | 249 | test('should generate new sessionId keeping ignoreFields (async)', async (t) => { 250 | t.plan(4) 251 | const fastify = Fastify() 252 | 253 | const options = { 254 | secret: DEFAULT_SECRET, 255 | cookie: { secure: false } 256 | } 257 | let oldSessionId 258 | fastify.register(fastifyCookie) 259 | fastify.register(fastifySession, options) 260 | fastify.get('/', async (request, reply) => { 261 | oldSessionId = request.session.sessionId 262 | request.session.set('message', 'hello world') 263 | await request.session.regenerate(['message']) 264 | reply.send(200) 265 | }) 266 | fastify.get('/check', (request, reply) => { 267 | t.assert.notStrictEqual(request.session.sessionId, oldSessionId) 268 | t.assert.strictEqual(request.session.get('message'), 'hello world') 269 | reply.send(200) 270 | }) 271 | await fastify.listen({ port: 0 }) 272 | t.after(() => { fastify.close() }) 273 | 274 | const response1 = await fastify.inject({ 275 | url: '/' 276 | }) 277 | 278 | t.assert.strictEqual(response1.statusCode, 200) 279 | 280 | const response2 = await fastify.inject({ 281 | url: '/check', 282 | headers: { Cookie: response1.headers['set-cookie'] } 283 | }) 284 | 285 | t.assert.strictEqual(response2.statusCode, 200) 286 | }) 287 | 288 | test('should decorate the server with decryptSession', async t => { 289 | t.plan(2) 290 | const fastify = Fastify() 291 | 292 | const options = { secret: DEFAULT_SECRET } 293 | fastify.register(fastifyCookie) 294 | fastify.register(fastifySession, options) 295 | t.after(() => fastify.close()) 296 | 297 | t.assert.ok(await fastify.ready()) 298 | t.assert.ok(fastify.decryptSession) 299 | }) 300 | 301 | test('should decryptSession with custom request object', async (t) => { 302 | t.plan(5) 303 | const fastify = Fastify() 304 | 305 | const options = { 306 | secret: DEFAULT_SECRET 307 | } 308 | 309 | fastify.register(fastifyCookie) 310 | fastify.register(fastifySession, options) 311 | fastify.addHook('onRequest', (request, _reply, done) => { 312 | request.sessionStore.set(DEFAULT_SESSION_ID, { 313 | testData: 'this is a test', 314 | cookie: { secure: true, httpOnly: true, path: '/', expires: new Date(Date.now() + 1000) } 315 | }, done) 316 | }) 317 | 318 | fastify.get('/', (_request, reply) => { 319 | reply.send(200) 320 | }) 321 | await fastify.listen({ port: 0 }) 322 | t.after(() => { fastify.close() }) 323 | 324 | const response = await fastify.inject({ 325 | url: '/' 326 | }) 327 | t.assert.strictEqual(response.statusCode, 200) 328 | 329 | const { sessionId } = fastify.parseCookie(DEFAULT_COOKIE) 330 | const requestObj = {} 331 | fastify.decryptSession(sessionId, requestObj, () => { 332 | // it should be possible to save the session 333 | requestObj.session.save(err => { 334 | t.assert.ifError(err) 335 | }) 336 | t.assert.strictEqual(requestObj.session.cookie.originalMaxAge, null) 337 | t.assert.strictEqual(requestObj.session.testData, 'this is a test') 338 | t.assert.strictEqual(requestObj.session.sessionId, DEFAULT_SESSION_ID) 339 | }) 340 | }) 341 | 342 | test('should decryptSession with custom cookie options', async (t) => { 343 | t.plan(2) 344 | const fastify = Fastify() 345 | 346 | const options = { 347 | secret: DEFAULT_SECRET 348 | } 349 | 350 | fastify.register(fastifyCookie) 351 | fastify.register(fastifySession, options) 352 | 353 | fastify.get('/', (_request, reply) => { 354 | reply.send(200) 355 | }) 356 | await fastify.listen({ port: 0 }) 357 | t.after(() => { fastify.close() }) 358 | 359 | const response = await fastify.inject({ 360 | url: '/' 361 | }) 362 | t.assert.strictEqual(response.statusCode, 200) 363 | 364 | const { sessionId } = fastify.parseCookie(DEFAULT_COOKIE) 365 | const requestObj = {} 366 | fastify.decryptSession(sessionId, requestObj, { maxAge: 86400 }, () => { 367 | t.assert.strictEqual(requestObj.session.cookie.originalMaxAge, 86400) 368 | }) 369 | }) 370 | 371 | test('should bubble up errors with destroy call if session expired', async (t) => { 372 | t.plan(2) 373 | const fastify = Fastify() 374 | const store = { 375 | set (_id, _data, cb) { cb(null) }, 376 | get (_id, cb) { 377 | cb(null, { cookie: { expires: new Date(Date.now() - 1000) } }) 378 | }, 379 | destroy (_id, cb) { cb(new Error('No can do')) } 380 | } 381 | 382 | const options = { 383 | secret: DEFAULT_SECRET, 384 | store, 385 | cookie: { secure: false } 386 | } 387 | 388 | fastify.register(fastifyCookie) 389 | fastify.register(fastifySession, options) 390 | 391 | fastify.get('/', (_request, reply) => { 392 | reply.send(200) 393 | }) 394 | await fastify.listen({ port: 0 }) 395 | t.after(() => { fastify.close() }) 396 | 397 | const response = await fastify.inject({ 398 | url: '/', 399 | headers: { cookie: 'sessionId=_TuQsCBgxtHB3bu6wsRpTXfjqR5sK-q_.3mu5mErW+QI7w+Q0V2fZtrztSvqIpYgsnnC8LQf6ERY;' } 400 | }) 401 | t.assert.strictEqual(response.statusCode, 500) 402 | t.assert.strictEqual(JSON.parse(response.body).message, 'No can do') 403 | }) 404 | 405 | test('should not reset session cookie expiration if rolling is false', async (t) => { 406 | t.plan(3) 407 | 408 | const fastify = Fastify() 409 | 410 | const options = { 411 | secret: DEFAULT_SECRET, 412 | rolling: false, 413 | cookie: { secure: false, maxAge: 10000 } 414 | } 415 | fastify.register(fastifyCookie) 416 | fastify.register(fastifySession, options) 417 | fastify.addHook('onRequest', async (request, reply) => { 418 | await sleep(1) 419 | reply.send(request.session.expires) 420 | }) 421 | 422 | fastify.get('/', (_request, reply) => reply.send(200)) 423 | fastify.get('/check', (_request, reply) => reply.send(200)) 424 | await fastify.listen({ port: 0 }) 425 | t.after(() => { fastify.close() }) 426 | 427 | const response1 = await fastify.inject({ 428 | url: '/' 429 | }) 430 | t.assert.strictEqual(response1.statusCode, 200) 431 | 432 | const response2 = await fastify.inject({ 433 | url: '/check', 434 | headers: { Cookie: response1.headers['set-cookie'] } 435 | }) 436 | t.assert.strictEqual(response2.statusCode, 200) 437 | 438 | t.assert.strictEqual(response1.body, response2.body) 439 | }) 440 | 441 | test('should update the expires property of the session using Session#touch() even if rolling is false', async (t) => { 442 | t.plan(3) 443 | 444 | const fastify = Fastify() 445 | 446 | const options = { 447 | secret: DEFAULT_SECRET, 448 | rolling: false, 449 | cookie: { secure: false, maxAge: 10000 } 450 | } 451 | fastify.register(fastifyCookie) 452 | fastify.register(fastifySession, options) 453 | fastify.addHook('onRequest', async (request, reply) => { 454 | await sleep(1) 455 | request.session.touch() 456 | reply.send(request.session.cookie.expires) 457 | }) 458 | 459 | fastify.get('/', (_request, reply) => reply.send(200)) 460 | fastify.get('/check', (_request, reply) => reply.send(200)) 461 | await fastify.listen({ port: 0 }) 462 | t.after(() => { fastify.close() }) 463 | 464 | const response1 = await fastify.inject({ 465 | url: '/' 466 | }) 467 | t.assert.strictEqual(response1.statusCode, 200) 468 | 469 | await new Promise(resolve => setTimeout(resolve, 1)) 470 | 471 | const response2 = await fastify.inject({ 472 | url: '/check', 473 | headers: { Cookie: response1.headers['set-cookie'] } 474 | }) 475 | t.assert.strictEqual(response2.statusCode, 200) 476 | 477 | t.assert.notStrictEqual(response1.body, response2.body) 478 | }) 479 | 480 | test('should use custom sessionId generator if available (with request)', async (t) => { 481 | const fastify = Fastify() 482 | fastify.register(fastifyCookie) 483 | fastify.register(fastifySession, { 484 | secret: DEFAULT_SECRET, 485 | cookie: { secure: false, maxAge: 10000 }, 486 | idGenerator: (request) => { 487 | if (request.session?.returningVisitor) return `returningVisitor-${new Date().getTime()}` 488 | else return `custom-${new Date().getTime()}` 489 | } 490 | }) 491 | t.after(() => fastify.close()) 492 | 493 | fastify.get('/', (request, reply) => { 494 | reply.status(200).send(request.session.sessionId) 495 | }) 496 | fastify.get('/login', (request, reply) => { 497 | request.session.returningVisitor = true 498 | request.session.regenerate(error => { 499 | if (error) { 500 | reply.status(500).send('Error ' + error) 501 | } else { 502 | reply.status(200).send('OK ' + request.session.sessionId) 503 | } 504 | }) 505 | }) 506 | await fastify.listen({ port: 0 }) 507 | t.after(() => { fastify.close() }) 508 | 509 | const response1 = await fastify.inject({ 510 | url: '/' 511 | }) 512 | t.assert.strictEqual(response1.statusCode, 200) 513 | t.assert.notStrictEqual(response1.headers['set-cookie'], undefined) 514 | t.assert.ok(response1.body.startsWith('custom-')) 515 | 516 | const response2 = await fastify.inject({ 517 | url: '/login', 518 | headers: { Cookie: response1.headers['set-cookie'] } 519 | }) 520 | t.assert.strictEqual(response2.statusCode, 200) 521 | t.assert.notStrictEqual(response2.headers['set-cookie'], undefined) 522 | 523 | const response3 = await fastify.inject({ 524 | url: '/', 525 | headers: { Cookie: response2.headers['set-cookie'] } 526 | }) 527 | t.assert.strictEqual(response3.statusCode, 200) 528 | t.assert.ok(response3.body.startsWith('returningVisitor-')) 529 | }) 530 | 531 | test('should use custom sessionId generator if available (with request and rolling false)', async (t) => { 532 | const fastify = Fastify() 533 | fastify.register(fastifyCookie) 534 | fastify.register(fastifySession, { 535 | secret: DEFAULT_SECRET, 536 | rolling: false, 537 | cookie: { secure: false, maxAge: 10000 }, 538 | idGenerator: (request) => { 539 | if (request.session?.returningVisitor) { 540 | return `returningVisitor-${ 541 | new Date().getTime() 542 | }-${ 543 | Math.random().toString().slice(2) 544 | }` 545 | } 546 | return `custom-${ 547 | new Date().getTime() 548 | }-${ 549 | Math.random().toString().slice(2) 550 | }` 551 | } 552 | }) 553 | t.after(() => fastify.close()) 554 | 555 | fastify.get('/', (request, reply) => { 556 | reply.status(200).send(request.session.sessionId) 557 | }) 558 | fastify.get('/login', (request, reply) => { 559 | request.session.returningVisitor = true 560 | request.session.regenerate(error => { 561 | if (error) { 562 | reply.status(500).send('Error ' + error) 563 | } else { 564 | reply.status(200).send('OK ' + request.session.sessionId) 565 | } 566 | }) 567 | }) 568 | await fastify.listen({ port: 0 }) 569 | t.after(() => { fastify.close() }) 570 | 571 | const response1 = await fastify.inject({ 572 | url: '/' 573 | }) 574 | t.assert.strictEqual(response1.statusCode, 200) 575 | t.assert.notStrictEqual(response1.headers['set-cookie'], undefined) 576 | t.assert.ok(response1.body.startsWith('custom-')) 577 | 578 | const response2 = await fastify.inject({ 579 | url: '/login', 580 | headers: { Cookie: response1.headers['set-cookie'] } 581 | }) 582 | t.assert.strictEqual(response2.statusCode, 200) 583 | t.assert.notStrictEqual(response2.headers['set-cookie'], undefined) 584 | 585 | const response3 = await fastify.inject({ 586 | url: '/', 587 | headers: { Cookie: response2.headers['set-cookie'] } 588 | }) 589 | t.assert.strictEqual(response3.statusCode, 200) 590 | t.assert.ok(response3.body.startsWith('returningVisitor-')) 591 | }) 592 | 593 | test('should reload the session', async (t) => { 594 | t.plan(4) 595 | const fastify = await buildFastify((request, reply) => { 596 | request.session.someData = 'some-data' 597 | t.assert.strictEqual(request.session.someData, 'some-data') 598 | 599 | request.session.reload((err) => { 600 | t.assert.deepStrictEqual(err, null) 601 | 602 | t.assert.strictEqual(request.session.someData, undefined) 603 | 604 | reply.send(200) 605 | }) 606 | }, DEFAULT_OPTIONS) 607 | t.after(() => fastify.close()) 608 | 609 | const response = await fastify.inject({ 610 | url: '/' 611 | }) 612 | 613 | t.assert.strictEqual(response.statusCode, 200) 614 | }) 615 | 616 | test('should save the session', async (t) => { 617 | t.plan(6) 618 | const fastify = await buildFastify((request, reply) => { 619 | request.session.someData = 'some-data' 620 | t.assert.strictEqual(request.session.someData, 'some-data') 621 | 622 | request.session.save((err) => { 623 | t.assert.ifError(err) 624 | 625 | t.assert.strictEqual(request.session.someData, 'some-data') 626 | 627 | // unlike previous test, here the session data remains after a save 628 | request.session.reload((err) => { 629 | t.assert.ifError(err) 630 | 631 | t.assert.strictEqual(request.session.someData, 'some-data') 632 | 633 | reply.send(200) 634 | }) 635 | }) 636 | }, DEFAULT_OPTIONS) 637 | t.after(() => fastify.close()) 638 | 639 | const response = await fastify.inject({ 640 | url: '/' 641 | }) 642 | 643 | t.assert.strictEqual(response.statusCode, 200) 644 | }) 645 | 646 | test('destroy supports promises', async t => { 647 | t.plan(2) 648 | const fastify = await buildFastify(async (request, reply) => { 649 | await t.assert.doesNotReject(request.session.destroy()) 650 | 651 | reply.send(200) 652 | }, DEFAULT_OPTIONS) 653 | t.after(() => fastify.close()) 654 | 655 | const response = await fastify.inject({ 656 | url: '/' 657 | }) 658 | 659 | t.assert.strictEqual(response.statusCode, 200) 660 | }) 661 | 662 | test('destroy supports rejecting promises', async t => { 663 | t.plan(3) 664 | const fastify = await buildFastify(async (request, reply) => { 665 | await t.assert.rejects( 666 | async () => request.session.destroy(), 667 | (err) => { 668 | t.assert.strictEqual(err.message, 'no can do') 669 | return true 670 | } 671 | ) 672 | 673 | reply.send(200) 674 | }, { 675 | ...DEFAULT_OPTIONS, 676 | store: { 677 | set (_id, _data, cb) { cb(null) }, 678 | get (_id, cb) { cb(null) }, 679 | destroy (_id, cb) { cb(new Error('no can do')) } 680 | } 681 | }) 682 | t.after(() => fastify.close()) 683 | 684 | const response = await fastify.inject({ 685 | url: '/' 686 | }) 687 | 688 | // 200 since we assert inline and swallow the error 689 | t.assert.strictEqual(response.statusCode, 200) 690 | }) 691 | 692 | test('regenerate supports promises', async t => { 693 | t.plan(2) 694 | const fastify = await buildFastify(async (request, reply) => { 695 | await t.assert.doesNotReject(request.session.regenerate()) 696 | 697 | reply.send(200) 698 | }, DEFAULT_OPTIONS) 699 | t.after(() => fastify.close()) 700 | 701 | const response = await fastify.inject({ 702 | url: '/' 703 | }) 704 | 705 | t.assert.strictEqual(response.statusCode, 200) 706 | }) 707 | 708 | test('regenerate supports rejecting promises', async t => { 709 | t.plan(3) 710 | const fastify = await buildFastify(async (request, reply) => { 711 | await t.assert.rejects( 712 | request.session.regenerate(), 713 | (err) => { 714 | t.assert.strictEqual(err.message, 'no can do') 715 | return true 716 | } 717 | ) 718 | 719 | reply.send(200) 720 | }, { 721 | ...DEFAULT_OPTIONS, 722 | store: { 723 | set (_id, _data, cb) { cb(new Error('no can do')) }, 724 | get (_id, cb) { cb(null) }, 725 | destroy (_id, cb) { cb(null) } 726 | } 727 | }) 728 | t.after(() => fastify.close()) 729 | 730 | const response = await fastify.inject({ 731 | url: '/' 732 | }) 733 | 734 | // 200 since we assert inline and swallow the error 735 | t.assert.strictEqual(response.statusCode, 200) 736 | }) 737 | 738 | test('reload supports promises', async t => { 739 | t.plan(2) 740 | const fastify = await buildFastify(async (request, reply) => { 741 | await t.assert.doesNotReject(request.session.reload()) 742 | 743 | reply.send(200) 744 | }, DEFAULT_OPTIONS) 745 | t.after(() => fastify.close()) 746 | 747 | const response = await fastify.inject({ 748 | url: '/' 749 | }) 750 | 751 | t.assert.strictEqual(response.statusCode, 200) 752 | }) 753 | 754 | test('reload supports rejecting promises', async t => { 755 | t.plan(3) 756 | const fastify = await buildFastify(async (request, reply) => { 757 | await t.assert.rejects( 758 | request.session.reload(), 759 | (err) => { 760 | t.assert.strictEqual(err.message, 'no can do') 761 | return true 762 | } 763 | ) 764 | 765 | reply.send(200) 766 | }, { 767 | ...DEFAULT_OPTIONS, 768 | store: { 769 | set (_id, _data, cb) { cb(null) }, 770 | get (_id, cb) { cb(new Error('no can do')) }, 771 | destroy (_id, cb) { cb(null) } 772 | } 773 | }) 774 | t.after(() => fastify.close()) 775 | 776 | const response = await fastify.inject({ 777 | url: '/' 778 | }) 779 | 780 | // 200 since we assert inline and swallow the error 781 | t.assert.strictEqual(response.statusCode, 200) 782 | }) 783 | 784 | test('save supports promises', async t => { 785 | t.plan(2) 786 | const fastify = await buildFastify(async (request, reply) => { 787 | await t.assert.doesNotReject(request.session.save()) 788 | 789 | reply.send(200) 790 | }, DEFAULT_OPTIONS) 791 | t.after(() => fastify.close()) 792 | 793 | const response = await fastify.inject({ 794 | url: '/' 795 | }) 796 | 797 | t.assert.strictEqual(response.statusCode, 200) 798 | }) 799 | 800 | test('save supports rejecting promises', async t => { 801 | t.plan(2) 802 | const fastify = await buildFastify(async (request, reply) => { 803 | await t.assert.rejects(request.session.save()) 804 | 805 | reply.send(200) 806 | }, { 807 | ...DEFAULT_OPTIONS, 808 | store: { 809 | set (_id, _data, cb) { cb(new Error('no can do')) }, 810 | get (_id, cb) { cb(null) }, 811 | destroy (_id, cb) { cb(null) } 812 | } 813 | }) 814 | t.after(() => fastify.close()) 815 | 816 | const response = await fastify.inject({ 817 | url: '/' 818 | }) 819 | 820 | // 200 since we assert inline and swallow the error 821 | t.assert.strictEqual(response.statusCode, 200) 822 | }) 823 | 824 | test("clears cookie if not backed by a session, and there's nothing to save", async t => { 825 | t.plan(2) 826 | const fastify = await buildFastify((_request, reply) => { 827 | reply.send(200) 828 | }, DEFAULT_OPTIONS) 829 | t.after(() => fastify.close()) 830 | 831 | const response = await fastify.inject({ 832 | url: '/', 833 | headers: { cookie: DEFAULT_COOKIE_VALUE } 834 | }) 835 | 836 | t.assert.strictEqual(response.statusCode, 200) 837 | t.assert.strictEqual(response.headers['set-cookie'], 'sessionId=; Max-Age=0; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax') 838 | }) 839 | 840 | test("clearing cookie sets the domain if it's specified in the cookie options", async t => { 841 | t.plan(2) 842 | const fastify = await buildFastify((_request, reply) => { 843 | reply.send(200) 844 | }, { 845 | ...DEFAULT_OPTIONS, 846 | cookie: { domain: 'domain.test' } 847 | }) 848 | t.after(() => fastify.close()) 849 | 850 | const response = await fastify.inject({ 851 | url: '/', 852 | headers: { cookie: DEFAULT_COOKIE_VALUE } 853 | }) 854 | 855 | t.assert.strictEqual(response.statusCode, 200) 856 | t.assert.strictEqual(response.headers['set-cookie'], 'sessionId=; Max-Age=0; Domain=domain.test; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax') 857 | }) 858 | 859 | test('does not clear cookie if no session cookie in request', async t => { 860 | t.plan(2) 861 | const fastify = await buildFastify((_request, reply) => { 862 | reply.send(200) 863 | }, DEFAULT_OPTIONS) 864 | t.after(() => fastify.close()) 865 | 866 | const response = await fastify.inject({ 867 | url: '/', 868 | headers: { cookie: 'someOtherCookie=foobar' } 869 | }) 870 | 871 | t.assert.strictEqual(response.statusCode, 200) 872 | t.assert.strictEqual(response.headers['set-cookie'], undefined) 873 | }) 874 | 875 | test('when rolling is false, only save session when it changes', async t => { 876 | t.plan(6) 877 | let setCount = 0 878 | const store = new Map() 879 | 880 | const fastify = Fastify() 881 | fastify.register(fastifyCookie) 882 | 883 | fastify.register(fastifySession, { 884 | ...DEFAULT_OPTIONS, 885 | saveUninitialized: false, 886 | cookie: { secure: false }, 887 | rolling: false, 888 | store: { 889 | set (id, data, cb) { 890 | ++setCount 891 | store.set(id, data) 892 | cb(null) 893 | }, 894 | get (id, cb) { cb(null, store.get(id)) }, 895 | destroy (id, cb) { 896 | store.delete(id) 897 | cb(null) 898 | } 899 | } 900 | }) 901 | 902 | fastify.get('/', (request, reply) => { 903 | request.session.userId = 42 904 | 905 | reply.send(200) 906 | }) 907 | 908 | const response1 = await fastify.inject('/') 909 | const setCookieHeader1 = response1.headers['set-cookie'] 910 | 911 | t.assert.strictEqual(response1.statusCode, 200) 912 | t.assert.strictEqual(setCount, 1) 913 | t.assert.strictEqual(typeof setCookieHeader1, 'string') 914 | 915 | const { sessionId } = fastify.parseCookie(setCookieHeader1) 916 | 917 | const response2 = await fastify.inject({ path: '/', headers: { cookie: `sessionId=${sessionId}` } }) 918 | const setCookieHeader2 = response2.headers['set-cookie'] 919 | 920 | t.assert.strictEqual(response2.statusCode, 200) 921 | // still only called once 922 | t.assert.strictEqual(setCount, 1) 923 | // no set-cookie 924 | t.assert.strictEqual(setCookieHeader2, undefined) 925 | }) 926 | 927 | test('when rolling is false, only save session when it changes, but.assert.notStrictEqual if manually saved', async t => { 928 | t.plan(5) 929 | let setCount = 0 930 | const store = new Map() 931 | 932 | const fastify = Fastify() 933 | fastify.register(fastifyCookie) 934 | 935 | fastify.register(fastifySession, { 936 | ...DEFAULT_OPTIONS, 937 | saveUninitialized: false, 938 | cookie: { secure: false }, 939 | rolling: false, 940 | store: { 941 | set (id, data, cb) { 942 | ++setCount 943 | store.set(id, data) 944 | cb(null) 945 | }, 946 | get (id, cb) { cb(null, store.get(id)) }, 947 | destroy (id, cb) { 948 | store.delete(id) 949 | cb(null) 950 | } 951 | } 952 | }) 953 | 954 | fastify.get('/', async (request, reply) => { 955 | request.session.userId = 42 956 | 957 | t.assert.strictEqual(request.session.isModified(), true) 958 | 959 | // manually save the session 960 | await request.session.save() 961 | 962 | t.assert.strictEqual(request.session.isModified(), false) 963 | 964 | await reply.send(200) 965 | }) 966 | 967 | const { statusCode, headers } = await fastify.inject('/') 968 | 969 | t.assert.strictEqual(statusCode, 200) 970 | // we manually saved the session, so it should be called once (not once for manual save and once in `onSend`) 971 | t.assert.strictEqual(setCount, 1) 972 | t.assert.strictEqual(typeof headers['set-cookie'], 'string') 973 | }) 974 | 975 | test('when rolling is true, keep saving the session', async t => { 976 | t.plan(6) 977 | let setCount = 0 978 | const store = new Map() 979 | 980 | const fastify = Fastify() 981 | fastify.register(fastifyCookie) 982 | 983 | fastify.register(fastifySession, { 984 | ...DEFAULT_OPTIONS, 985 | saveUninitialized: false, 986 | cookie: { secure: false }, 987 | rolling: true, 988 | store: { 989 | set (id, data, cb) { 990 | ++setCount 991 | store.set(id, data) 992 | cb(null) 993 | }, 994 | get (id, cb) { cb(null, store.get(id)) }, 995 | destroy (id, cb) { 996 | store.delete(id) 997 | cb(null) 998 | } 999 | } 1000 | }) 1001 | 1002 | fastify.get('/', (request, reply) => { 1003 | request.session.userId = 42 1004 | 1005 | reply.send(200) 1006 | }) 1007 | 1008 | const response1 = await fastify.inject('/') 1009 | const setCookieHeader1 = response1.headers['set-cookie'] 1010 | 1011 | t.assert.strictEqual(response1.statusCode, 200) 1012 | t.assert.strictEqual(setCount, 1) 1013 | t.assert.strictEqual(typeof setCookieHeader1, 'string') 1014 | 1015 | const { sessionId } = fastify.parseCookie(setCookieHeader1) 1016 | 1017 | const response2 = await fastify.inject({ path: '/', headers: { cookie: `sessionId=${sessionId}` } }) 1018 | const setCookieHeader2 = response2.headers['set-cookie'] 1019 | 1020 | t.assert.strictEqual(response2.statusCode, 200) 1021 | t.assert.strictEqual(setCount, 2) 1022 | t.assert.strictEqual(typeof setCookieHeader2, 'string') 1023 | }) 1024 | 1025 | test('will not update expires property of the session using Session#touch() if maxAge is not set', async (t) => { 1026 | t.plan(4) 1027 | 1028 | const fastify = Fastify() 1029 | fastify.register(fastifyCookie) 1030 | fastify.register(fastifySession, { 1031 | secret: DEFAULT_SECRET, 1032 | rolling: false, 1033 | cookie: { secure: false } 1034 | }) 1035 | fastify.addHook('onRequest', (request, _reply, done) => { 1036 | request.session.touch() 1037 | done() 1038 | }) 1039 | 1040 | fastify.get('/', (request, reply) => reply.send({ expires: request.session.cookie.expires })) 1041 | await fastify.listen({ port: 0 }) 1042 | t.after(() => { fastify.close() }) 1043 | 1044 | const response1 = await fastify.inject({ 1045 | url: '/' 1046 | }) 1047 | t.assert.strictEqual(response1.statusCode, 200) 1048 | await new Promise(resolve => setTimeout(resolve, 1)) 1049 | t.assert.deepStrictEqual(response1.json(), { expires: null }) 1050 | 1051 | const response2 = await fastify.inject({ 1052 | url: '/', 1053 | headers: { Cookie: response1.headers['set-cookie'] } 1054 | }) 1055 | t.assert.strictEqual(response2.statusCode, 200) 1056 | t.assert.deepStrictEqual(response2.json(), { expires: null }) 1057 | }) 1058 | 1059 | test('should save session if existing, modified, rolling false, and cookie.expires null', async (t) => { 1060 | t.plan(8) 1061 | 1062 | const fastify = Fastify() 1063 | fastify.register(fastifyCookie) 1064 | fastify.register(fastifySession, { 1065 | ...DEFAULT_OPTIONS, 1066 | cookie: { secure: false }, 1067 | rolling: false 1068 | }) 1069 | fastify.get('/', (request, reply) => { 1070 | request.session.set('foo', 'bar') 1071 | t.assert.strictEqual(request.session.cookie.expires, null) 1072 | reply.send(200) 1073 | }) 1074 | fastify.get('/second', (request, reply) => { 1075 | t.assert.strictEqual(request.session.get('foo'), 'bar') 1076 | request.session.set('foo', 'baz') 1077 | t.assert.strictEqual(request.session.cookie.expires, null) 1078 | reply.send(200) 1079 | }) 1080 | fastify.get('/third', (request, reply) => { 1081 | t.assert.strictEqual(request.session.get('foo'), 'baz') 1082 | t.assert.strictEqual(request.session.cookie.expires, null) 1083 | reply.send(200) 1084 | }) 1085 | await fastify.listen({ port: 0 }) 1086 | t.after(() => { fastify.close() }) 1087 | 1088 | const response1 = await fastify.inject({ 1089 | url: '/' 1090 | }) 1091 | t.assert.strictEqual(response1.statusCode, 200) 1092 | 1093 | const response2 = await fastify.inject({ 1094 | url: '/second', 1095 | headers: { Cookie: response1.headers['set-cookie'] } 1096 | }) 1097 | t.assert.strictEqual(response2.statusCode, 200) 1098 | 1099 | const response3 = await fastify.inject({ 1100 | url: '/third', 1101 | headers: { Cookie: response1.headers['set-cookie'] } 1102 | }) 1103 | t.assert.strictEqual(response3.statusCode, 200) 1104 | }) 1105 | 1106 | test('Custom options', async t => { 1107 | t.plan(6) 1108 | 1109 | const fastify = Fastify() 1110 | fastify.register(fastifyCookie) 1111 | fastify.register(fastifySession, { 1112 | ...DEFAULT_OPTIONS, 1113 | cookie: { 1114 | secure: false, 1115 | path: '/' 1116 | } 1117 | }) 1118 | 1119 | fastify.post('/', (request, reply) => { 1120 | request.session.set('data', request.body) 1121 | request.session.options({ maxAge: 1000 * 60 * 60 }) 1122 | reply.send('hello world') 1123 | }) 1124 | 1125 | t.after(() => fastify.close.bind(fastify)) 1126 | 1127 | fastify.get('/', (request, reply) => { 1128 | const data = request.session.get('data') 1129 | if (!data) { 1130 | reply.code(404).send() 1131 | return 1132 | } 1133 | reply.send(data) 1134 | }) 1135 | 1136 | fastify.inject({ 1137 | method: 'POST', 1138 | url: '/', 1139 | payload: { 1140 | some: 'data' 1141 | } 1142 | }, (error, response) => { 1143 | t.assert.ifError(error) 1144 | t.assert.strictEqual(response.statusCode, 200) 1145 | t.assert.ok(response.headers['set-cookie']) 1146 | const { expires } = response.cookies[0] 1147 | t.assert.strictEqual(expires.toUTCString(), new Date(Date.now() + 1000 * 60 * 60).toUTCString()) 1148 | 1149 | fastify.inject({ 1150 | method: 'GET', 1151 | url: '/', 1152 | headers: { 1153 | cookie: response.headers['set-cookie'] 1154 | } 1155 | }, (error, response) => { 1156 | t.assert.ifError(error) 1157 | t.assert.deepStrictEqual(JSON.parse(response.payload), { some: 'data' }) 1158 | }) 1159 | }) 1160 | 1161 | await sleep() 1162 | }) 1163 | 1164 | test('Override global options', async t => { 1165 | t.plan(11) 1166 | 1167 | const fastify = Fastify() 1168 | await fastify.register(fastifyCookie) 1169 | await fastify.register(fastifySession, { 1170 | ...DEFAULT_OPTIONS, 1171 | cookie: { 1172 | secure: false, 1173 | maxAge: 42, 1174 | path: '/' 1175 | } 1176 | }) 1177 | 1178 | fastify.post('/', (request, reply) => { 1179 | request.session.set('data', request.body) 1180 | request.session.options({ maxAge: 1000 * 60 * 60 }) 1181 | 1182 | reply.send('hello world') 1183 | }) 1184 | 1185 | t.after(async () => await fastify.close()) 1186 | 1187 | fastify.get('/', (request, reply) => { 1188 | const data = request.session.get('data') 1189 | 1190 | if (!data) { 1191 | reply.code(404).send() 1192 | return 1193 | } 1194 | reply.send(data) 1195 | }) 1196 | 1197 | let response = await fastify.inject({ 1198 | method: 'POST', 1199 | url: '/', 1200 | payload: { 1201 | some: 'data' 1202 | } 1203 | }) 1204 | t.assert.ok(response) 1205 | t.assert.strictEqual(response.statusCode, 200) 1206 | t.assert.ok(response.headers['set-cookie']) 1207 | let cookie = response.cookies[0] 1208 | t.assert.strictEqual(cookie.expires.toUTCString(), new Date(Date.now() + 1000 * 60 * 60).toUTCString()) 1209 | t.assert.strictEqual(cookie.path, '/') 1210 | 1211 | response = await fastify.inject({ 1212 | method: 'GET', 1213 | url: '/', 1214 | headers: { 1215 | cookie: response.headers['set-cookie'] 1216 | } 1217 | }) 1218 | t.assert.ok(response) 1219 | t.assert.strictEqual(response.statusCode, 200) 1220 | t.assert.deepStrictEqual(JSON.parse(response.payload), { some: 'data' }) 1221 | t.assert.ok(response.headers['set-cookie']) 1222 | cookie = response.cookies[0] 1223 | t.assert.strictEqual(cookie.expires.toUTCString(), new Date(Date.now() + 1000 * 60 * 60).toUTCString()) 1224 | t.assert.strictEqual(cookie.path, '/') 1225 | }) 1226 | 1227 | test('Override global options with regenerate', async t => { 1228 | t.plan(11) 1229 | 1230 | const fastify = Fastify() 1231 | fastify.register(fastifyCookie) 1232 | fastify.register(fastifySession, { 1233 | ...DEFAULT_OPTIONS, 1234 | cookie: { 1235 | secure: false, 1236 | maxAge: 42, 1237 | path: '/' 1238 | } 1239 | }) 1240 | 1241 | fastify.post('/', (request, reply) => { 1242 | request.session.set('data', request.body) 1243 | request.session.options({ maxAge: 1000 * 60 * 60 }) // maxAge updated to 1 hour 1244 | 1245 | reply.send('hello world') 1246 | }) 1247 | 1248 | t.after(() => fastify.close.bind(fastify)) 1249 | 1250 | fastify.get('/', async (request, reply) => { 1251 | const data = request.session.get('data') 1252 | await request.session.regenerate() 1253 | 1254 | if (!data) { 1255 | reply.code(404).send() 1256 | return 1257 | } 1258 | 1259 | reply.send(data) 1260 | }) 1261 | 1262 | let response = await fastify.inject({ 1263 | method: 'POST', 1264 | url: '/', 1265 | payload: { 1266 | some: 'data' 1267 | } 1268 | }) 1269 | t.assert.ok(response) 1270 | t.assert.strictEqual(response.statusCode, 200) 1271 | t.assert.ok(response.headers['set-cookie']) 1272 | let cookie = response.cookies[0] 1273 | t.assert.strictEqual(cookie.expires.toUTCString(), new Date(Date.now() + 1000 * 60 * 60).toUTCString()) 1274 | t.assert.strictEqual(cookie.path, '/') 1275 | 1276 | response = await fastify.inject({ 1277 | method: 'GET', 1278 | url: '/', 1279 | headers: { 1280 | cookie: response.headers['set-cookie'] 1281 | } 1282 | }) 1283 | 1284 | t.assert.ok(response) 1285 | t.assert.strictEqual(response.statusCode, 200) 1286 | t.assert.deepStrictEqual(JSON.parse(response.payload), { some: 'data' }) 1287 | t.assert.ok(response.headers['set-cookie']) 1288 | cookie = response.cookies[0] 1289 | t.assert.strictEqual(cookie.expires.toUTCString(), new Date(Date.now() + 1000 * 60 * 60).toUTCString()) 1290 | t.assert.strictEqual(cookie.path, '/') 1291 | }) 1292 | -------------------------------------------------------------------------------- /test/store.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const test = require('node:test') 4 | const fastifyPlugin = require('fastify-plugin') 5 | const { buildFastify, DEFAULT_OPTIONS, DEFAULT_COOKIE, DEFAULT_SECRET, DEFAULT_SESSION_ID } = require('./util') 6 | 7 | test('should decorate request with sessionStore', async (t) => { 8 | t.plan(2) 9 | 10 | const fastify = await buildFastify((request, reply) => { 11 | t.assert.ok(request.sessionStore) 12 | reply.send(200) 13 | }, DEFAULT_OPTIONS) 14 | t.after(() => fastify.close()) 15 | 16 | const response = await fastify.inject({ 17 | method: 'GET', 18 | url: '/' 19 | }) 20 | 21 | t.assert.strictEqual(response.statusCode, 200) 22 | }) 23 | 24 | test('should pass error on store.set to done', async (t) => { 25 | t.plan(1) 26 | const options = { 27 | secret: DEFAULT_SECRET, 28 | store: new FailingStore() 29 | } 30 | const fastify = await buildFastify((request, reply) => { 31 | request.session.test = {} 32 | reply.send(200) 33 | }, options) 34 | t.after(() => fastify.close()) 35 | 36 | const { statusCode } = await fastify.inject({ 37 | method: 'GET', 38 | url: '/', 39 | headers: { 'x-forwarded-proto': 'https' } 40 | }) 41 | 42 | t.assert.strictEqual(statusCode, 500) 43 | }) 44 | 45 | test('should create new session if ENOENT error on store.get', async (t) => { 46 | t.plan(5) 47 | const options = { 48 | secret: DEFAULT_SECRET, 49 | store: new EnoentErrorStore() 50 | } 51 | const fastify = await buildFastify((request, reply) => { 52 | request.session.test = {} 53 | reply.send(200) 54 | }, options) 55 | t.after(() => fastify.close()) 56 | 57 | const response = await fastify.inject({ 58 | method: 'GET', 59 | url: '/', 60 | headers: { 61 | cookie: DEFAULT_COOKIE, 62 | 'x-forwarded-proto': 'https' 63 | } 64 | }) 65 | 66 | t.assert.strictEqual(response.headers['set-cookie'].includes('AAzZgRQddT1TKLkT3OZcnPsDiLKgV1uM1XHy2bIyqIg'), false) 67 | const pattern = String.raw`sessionId=[\w-]{32}.[\w-%]{43,57}; Path=\/; HttpOnly; Secure` 68 | t.assert.strictEqual(RegExp(pattern).test(response.headers['set-cookie']), true) 69 | t.assert.strictEqual(response.statusCode, 200) 70 | t.assert.strictEqual(response.cookies[0].name, 'sessionId') 71 | t.assert.strictEqual(response.cookies[0].value.includes('AAzZgRQddT1TKLkT3OZcnPsDiLKgV1uM1XHy2bIyqIg'), false) 72 | }) 73 | 74 | test('should pass error to done if non-ENOENT error on store.get', async (t) => { 75 | t.plan(1) 76 | const options = { 77 | secret: DEFAULT_SECRET, 78 | store: new FailingStore() 79 | } 80 | 81 | const fastify = await buildFastify((_request, reply) => { 82 | reply.send(200) 83 | }, options) 84 | t.after(() => fastify.close()) 85 | 86 | const { statusCode } = await fastify.inject({ 87 | method: 'GET', 88 | url: '/', 89 | headers: { cookie: DEFAULT_COOKIE } 90 | }) 91 | 92 | t.assert.strictEqual(statusCode, 500) 93 | }) 94 | 95 | test('should set new session cookie if expired', async (t) => { 96 | t.plan(2) 97 | const options = { 98 | secret: DEFAULT_SECRET, 99 | store: new FailOnDestroyStore() 100 | } 101 | const plugin = fastifyPlugin(async (fastify) => { 102 | fastify.addHook('onRequest', (request, _reply, done) => { 103 | request.sessionStore.set(DEFAULT_SESSION_ID, { 104 | cookie: { 105 | expires: new Date(Date.now() - 1000) 106 | } 107 | }, done) 108 | }) 109 | }) 110 | function handler (request, reply) { 111 | request.session.test = {} 112 | reply.send(200) 113 | } 114 | const fastify = await buildFastify(handler, options, plugin) 115 | t.after(() => fastify.close()) 116 | 117 | const { statusCode, cookie } = await fastify.inject({ 118 | method: 'GET', 119 | url: '/', 120 | headers: { cookie: DEFAULT_COOKIE } 121 | }) 122 | 123 | t.assert.strictEqual(statusCode, 500) 124 | t.assert.strictEqual(cookie, undefined) 125 | }) 126 | 127 | class FailOnDestroyStore { 128 | constructor () { 129 | this.store = {} 130 | } 131 | 132 | set (sessionId, session, callback) { 133 | this.store[sessionId] = session 134 | callback() 135 | } 136 | 137 | get (sessionId, callback) { 138 | const session = this.store[sessionId] 139 | callback(null, session) 140 | } 141 | 142 | destroy (_sessionId, callback) { 143 | callback(new Error()) 144 | } 145 | } 146 | 147 | class EnoentErrorStore { 148 | constructor () { 149 | this.store = {} 150 | } 151 | 152 | set (sessionId, session, callback) { 153 | this.store[sessionId] = session 154 | callback() 155 | } 156 | 157 | get (_sessionId, callback) { 158 | const error = Object.assign(new Error(), { code: 'ENOENT' }) 159 | callback(error) 160 | } 161 | 162 | destroy (sessionId, callback) { 163 | this.store[sessionId] = undefined 164 | callback() 165 | } 166 | } 167 | 168 | class FailingStore { 169 | set (_sessionId, _session, callback) { 170 | callback(new Error('store.set')) 171 | } 172 | 173 | get (_sessionId, callback) { 174 | callback(new Error()) 175 | } 176 | 177 | destroy (_sessionId, callback) { 178 | callback(new Error()) 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /test/util.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Fastify = require('fastify') 4 | const fastifyCookie = require('@fastify/cookie') 5 | const fastifySession = require('../lib/fastifySession') 6 | const TestStore = require('./TestStore') 7 | 8 | const DEFAULT_SECRET = 'cNaoPYAwF60HZJzkcNaoPYAwF60HZJzk' 9 | const DEFAULT_OPTIONS = { secret: DEFAULT_SECRET } 10 | const DEFAULT_SESSION_ID = 'Qk_XT2K7-clT-x1tVvoY6tIQ83iP72KN' 11 | const DEFAULT_ENCRYPTED_SESSION_ID = `${DEFAULT_SESSION_ID}.B7fUDYXU9fXF9pNuL3qm4NVmSduLJ6kzCOPh5JhHGoE` 12 | const DEFAULT_COOKIE_VALUE = `sessionId=${DEFAULT_ENCRYPTED_SESSION_ID};` 13 | const DEFAULT_COOKIE = `${DEFAULT_COOKIE_VALUE}; Path=/; HttpOnly; Secure` 14 | 15 | async function buildFastify (handler, sessionOptions, plugin) { 16 | const fastify = Fastify({ trustProxy: true }) 17 | await fastify.register(fastifyCookie) 18 | if (plugin) { 19 | await fastify.register(plugin) 20 | } 21 | await fastify.register(fastifySession, { store: new TestStore(), ...sessionOptions }) 22 | 23 | fastify.get('/', handler) 24 | await fastify.listen({ port: 0 }) 25 | return fastify 26 | } 27 | 28 | module.exports = { 29 | buildFastify, 30 | DEFAULT_SECRET, 31 | DEFAULT_OPTIONS, 32 | DEFAULT_SESSION_ID, 33 | DEFAULT_ENCRYPTED_SESSION_ID, 34 | DEFAULT_COOKIE_VALUE, 35 | DEFAULT_COOKIE 36 | } 37 | -------------------------------------------------------------------------------- /test/verifyPath.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const test = require('node:test') 4 | const Fastify = require('fastify') 5 | const fastifyCookie = require('@fastify/cookie') 6 | const fastifySession = require('..') 7 | const { DEFAULT_SECRET } = require('./util') 8 | 9 | test('should handle path properly /1', async (t) => { 10 | t.plan(3) 11 | const fastify = Fastify() 12 | 13 | const options = { 14 | secret: DEFAULT_SECRET, 15 | cookie: { secure: false, path: '/' } 16 | } 17 | await fastify.register(fastifyCookie) 18 | await fastify.register(fastifySession, options) 19 | fastify.get('/', (request, reply) => { 20 | request.session.foo = 'bar' 21 | reply.send(200) 22 | }) 23 | fastify.get('/check', (request, reply) => { 24 | t.assert.strictEqual(request.session.foo, 'bar') 25 | reply.send(200) 26 | }) 27 | 28 | const response1 = await fastify.inject({ 29 | url: '/' 30 | }) 31 | 32 | t.assert.strictEqual(response1.statusCode, 200) 33 | 34 | const response2 = await fastify.inject({ 35 | url: '/check', 36 | headers: { Cookie: response1.headers['set-cookie'] } 37 | }) 38 | 39 | t.assert.strictEqual(response2.statusCode, 200) 40 | }) 41 | 42 | test('should handle path properly /2', async (t) => { 43 | t.plan(3) 44 | const fastify = Fastify() 45 | 46 | const options = { 47 | secret: DEFAULT_SECRET, 48 | cookie: { secure: false, path: '/check' } 49 | } 50 | await fastify.register(fastifyCookie) 51 | await fastify.register(fastifySession, options) 52 | fastify.post('/check', (request, reply) => { 53 | request.session.foo = 'bar' 54 | reply.send(200) 55 | }) 56 | fastify.get('/check', (request, reply) => { 57 | t.assert.strictEqual(request.session.foo, 'bar') 58 | reply.send(200) 59 | }) 60 | 61 | const response1 = await fastify.inject({ 62 | url: '/check', 63 | method: 'POST' 64 | }) 65 | 66 | t.assert.strictEqual(response1.statusCode, 200) 67 | 68 | const response2 = await fastify.inject({ 69 | url: '/check', 70 | headers: { Cookie: response1.headers['set-cookie'] } 71 | }) 72 | 73 | t.assert.strictEqual(response2.statusCode, 200) 74 | }) 75 | 76 | test('should handle path properly /2', async (t) => { 77 | t.plan(3) 78 | const fastify = Fastify() 79 | 80 | const options = { 81 | secret: DEFAULT_SECRET, 82 | cookie: { secure: false, path: '/check' } 83 | } 84 | await fastify.register(fastifyCookie) 85 | await fastify.register(fastifySession, options) 86 | fastify.post('/check', (request, reply) => { 87 | request.session.foo = 'bar' 88 | reply.send(200) 89 | }) 90 | fastify.get('/check/page1', (request, reply) => { 91 | t.assert.strictEqual(request.session.foo, 'bar') 92 | reply.send(200) 93 | }) 94 | 95 | await fastify.ready() 96 | 97 | const response1 = await fastify.inject({ 98 | url: '/check', 99 | method: 'POST' 100 | }) 101 | 102 | t.assert.strictEqual(response1.statusCode, 200) 103 | 104 | const response2 = await fastify.inject({ 105 | url: '/check/page1', 106 | headers: { Cookie: response1.headers['set-cookie'] } 107 | }) 108 | 109 | t.assert.strictEqual(response2.statusCode, 200) 110 | }) 111 | 112 | test('should handle path properly /3', async (t) => { 113 | t.plan(3) 114 | const fastify = Fastify() 115 | 116 | const options = { 117 | secret: DEFAULT_SECRET, 118 | cookie: { secure: false, path: '/check' } 119 | } 120 | await fastify.register(fastifyCookie) 121 | await fastify.register(fastifySession, options) 122 | fastify.post('/check', (request, reply) => { 123 | request.session.foo = 'bar' 124 | reply.send(200) 125 | }) 126 | fastify.get('/check_page1', (request, reply) => { 127 | t.assert.strictEqual(request.session.foo, undefined) 128 | reply.send(200) 129 | }) 130 | await fastify.ready() 131 | 132 | const response1 = await fastify.inject({ 133 | url: '/check', 134 | method: 'POST' 135 | }) 136 | 137 | t.assert.strictEqual(response1.statusCode, 200) 138 | 139 | const response2 = await fastify.inject({ 140 | url: '/check_page1', 141 | headers: { Cookie: response1.headers['set-cookie'] } 142 | }) 143 | 144 | t.assert.strictEqual(response2.statusCode, 200) 145 | }) 146 | 147 | test('should handle path properly /4', async (t) => { 148 | t.plan(3) 149 | const fastify = Fastify() 150 | 151 | const options = { 152 | secret: DEFAULT_SECRET, 153 | cookie: { secure: false, path: '/check' } 154 | } 155 | fastify.register(fastifyCookie) 156 | fastify.register(fastifySession, options) 157 | fastify.post('/check', (request, reply) => { 158 | request.session.foo = 'bar' 159 | reply.send(200) 160 | }) 161 | fastify.get('/chick/page1', (request, reply) => { 162 | t.assert.strictEqual(request.session.foo, undefined) 163 | reply.send(200) 164 | }) 165 | 166 | const response1 = await fastify.inject({ 167 | url: '/check', 168 | method: 'POST' 169 | }) 170 | 171 | t.assert.strictEqual(response1.statusCode, 200) 172 | 173 | const response2 = await fastify.inject({ 174 | url: '/chick/page1', 175 | headers: { Cookie: response1.headers['set-cookie'] } 176 | }) 177 | 178 | t.assert.strictEqual(response2.statusCode, 200) 179 | }) 180 | 181 | test('should handle path properly /4', async (t) => { 182 | t.plan(3) 183 | const fastify = Fastify() 184 | 185 | const options = { 186 | secret: DEFAULT_SECRET, 187 | cookie: { secure: false, path: '/check' } 188 | } 189 | await fastify.register(fastifyCookie) 190 | await fastify.register(fastifySession, options) 191 | fastify.post('/check', (request, reply) => { 192 | request.session.foo = 'bar' 193 | reply.send(200) 194 | }) 195 | fastify.get('/chick/page1', (request, reply) => { 196 | t.assert.strictEqual(request.session.foo, undefined) 197 | reply.send(200) 198 | }) 199 | 200 | const response1 = await fastify.inject({ 201 | url: '/check', 202 | method: 'POST' 203 | }) 204 | 205 | t.assert.strictEqual(response1.statusCode, 200) 206 | 207 | const response2 = await fastify.inject({ 208 | url: '/chick/page1', 209 | headers: { Cookie: response1.headers['set-cookie'] } 210 | }) 211 | 212 | t.assert.strictEqual(response2.statusCode, 200) 213 | }) 214 | 215 | test('should handle path properly /5', async (t) => { 216 | t.plan(3) 217 | const fastify = Fastify() 218 | 219 | const options = { 220 | secret: DEFAULT_SECRET, 221 | cookie: { secure: false, path: '/check' } 222 | } 223 | await fastify.register(fastifyCookie) 224 | await fastify.register(fastifySession, options) 225 | fastify.post('/check', (request, reply) => { 226 | request.session.foo = 'bar' 227 | reply.send(200) 228 | }) 229 | fastify.get('/chck', (request, reply) => { 230 | t.assert.strictEqual(request.session.foo, undefined) 231 | reply.send(200) 232 | }) 233 | 234 | const response1 = await fastify.inject({ 235 | url: '/check', 236 | method: 'POST' 237 | }) 238 | 239 | t.assert.strictEqual(response1.statusCode, 200) 240 | 241 | const response2 = await fastify.inject({ 242 | url: '/chck', 243 | headers: { Cookie: response1.headers['set-cookie'] } 244 | }) 245 | 246 | t.assert.strictEqual(response2.statusCode, 200) 247 | }) 248 | 249 | test('should handle path properly /6', async (t) => { 250 | t.plan(3) 251 | const fastify = Fastify() 252 | 253 | const options = { 254 | secret: DEFAULT_SECRET, 255 | cookie: { secure: false, path: '/check' } 256 | } 257 | fastify.register(fastifyCookie) 258 | fastify.register(fastifySession, options) 259 | fastify.post('/check/index', (request, reply) => { 260 | request.session.foo = 'bar' 261 | reply.send(200) 262 | }) 263 | fastify.get('/check/page1', (request, reply) => { 264 | t.assert.strictEqual(request.session.foo, 'bar') 265 | reply.send(200) 266 | }) 267 | 268 | const response1 = await fastify.inject({ 269 | url: '/check/index', 270 | method: 'POST' 271 | }) 272 | 273 | t.assert.strictEqual(response1.statusCode, 200) 274 | 275 | const response2 = await fastify.inject({ 276 | url: '/check/page1', 277 | headers: { Cookie: response1.headers['set-cookie'] } 278 | }) 279 | 280 | t.assert.strictEqual(response2.statusCode, 200) 281 | }) 282 | 283 | test('should handle path properly /7', async (t) => { 284 | t.plan(3) 285 | const fastify = Fastify() 286 | 287 | const options = { 288 | secret: DEFAULT_SECRET, 289 | cookie: { secure: false, path: '/check/' } 290 | } 291 | fastify.register(fastifyCookie) 292 | fastify.register(fastifySession, options) 293 | fastify.post('/check/index', (request, reply) => { 294 | request.session.foo = 'bar' 295 | reply.send(200) 296 | }) 297 | fastify.get('/check/page1', (request, reply) => { 298 | t.assert.strictEqual(request.session.foo, 'bar') 299 | reply.send(200) 300 | }) 301 | 302 | const response1 = await fastify.inject({ 303 | url: '/check/index', 304 | method: 'POST' 305 | }) 306 | 307 | t.assert.strictEqual(response1.statusCode, 200) 308 | 309 | const response2 = await fastify.inject({ 310 | url: '/check/page1', 311 | headers: { Cookie: response1.headers['set-cookie'] } 312 | }) 313 | 314 | t.assert.strictEqual(response2.statusCode, 200) 315 | }) 316 | 317 | // test('should handle path properly /8', async (t) => { 318 | // t.plan(7) 319 | // const fastify = Fastify() 320 | // 321 | // const options = { 322 | // secret: DEFAULT_SECRET, 323 | // cookie: { secure: false, path: '/check' } 324 | // } 325 | // await fastify.register(fastifyCookie) 326 | // await fastify.register(fastifySession, options) 327 | // fastify.post('/check/index', async (request, reply) => { 328 | // request.session.foo = 'bar' 329 | // reply.send(200) 330 | // }) 331 | // fastify.get('/check/page1', async (request, reply) => { 332 | // t.assert.strictEqual(request.session.foo, 'bar') 333 | // reply.send(200) 334 | // }) 335 | // fastify.get('/chck/page1', async (request, reply) => { 336 | // t.assert.strictEqual(request.session.foo, null) 337 | // reply.send(200) 338 | // }) 339 | // 340 | // const response1 = await fastify.inject({ 341 | // url: '/check/index', 342 | // method: 'POST' 343 | // }) 344 | // 345 | // t.assert.strictEqual(response1.statusCode, 200) 346 | // 347 | // const response2 = await fastify.inject({ 348 | // url: '/check/page1', 349 | // headers: { Cookie: response1.headers['set-cookie'] } 350 | // }) 351 | // t.assert.strictEqual(response2.statusCode, 200) 352 | // 353 | // const response3 = await fastify.inject({ 354 | // url: '/chck/page1', 355 | // headers: { Cookie: response1.headers['set-cookie'] } 356 | // }) 357 | // t.assert.strictEqual(response3.statusCode, 200) 358 | // 359 | // const response4 = await fastify.inject({ 360 | // url: '/check/page1', 361 | // headers: { Cookie: response1.headers['set-cookie'] } 362 | // }) 363 | // t.assert.strictEqual(response4.statusCode, 200) 364 | // }) 365 | 366 | test('should handle path properly /9', async (t) => { 367 | // Let's check that a search part of the url doesn't spoil the path verification 368 | 369 | t.plan(3) 370 | const fastify = Fastify() 371 | 372 | const options = { 373 | secret: DEFAULT_SECRET, 374 | cookie: { secure: false, path: '/check' } 375 | } 376 | fastify.register(fastifyCookie) 377 | fastify.register(fastifySession, options) 378 | fastify.get('/check', (request, reply) => { 379 | request.session.foo = 'bar' 380 | reply.send(200) 381 | }) 382 | fastify.get('/check/page1', (request, reply) => { 383 | t.assert.strictEqual(request.session.foo, 'bar') 384 | reply.send(200) 385 | }) 386 | 387 | const response1 = await fastify.inject({ 388 | url: '/check', 389 | query: { 390 | foo: 'bar' 391 | } 392 | }) 393 | 394 | t.assert.strictEqual(response1.statusCode, 200) 395 | 396 | const response2 = await fastify.inject({ 397 | url: '/check/page1', 398 | headers: { Cookie: response1.headers['set-cookie'] } 399 | }) 400 | 401 | t.assert.strictEqual(response2.statusCode, 200) 402 | }) 403 | -------------------------------------------------------------------------------- /types/types.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import type * as Fastify from 'fastify' 4 | import { FastifyPluginCallback } from 'fastify' 5 | import { CookieSerializeOptions } from '@fastify/cookie' 6 | 7 | declare module 'fastify' { 8 | interface FastifyInstance { 9 | decryptSession = FastifyRequest>(sessionId: string, request: Request, cookieOpts: fastifySession.CookieOptions, callback: Callback): void; 10 | decryptSession = FastifyRequest>(sessionId: string, request: Request, callback: Callback): void; 11 | } 12 | 13 | interface FastifyRequest { 14 | /** Allows to access or modify the session data. */ 15 | session: fastifySession.FastifySessionObject; 16 | 17 | /** A session store. */ 18 | sessionStore: Readonly; 19 | } 20 | 21 | interface Session extends ExpressSessionData { } 22 | } 23 | 24 | type FastifySession = FastifyPluginCallback & { 25 | Store: fastifySession.MemoryStore, 26 | MemoryStore: fastifySession.MemoryStore, 27 | } 28 | 29 | type Callback = (err?: any) => void 30 | type CallbackSession = (err: any, result?: Fastify.Session | null) => void 31 | 32 | interface ExpressSessionData { 33 | /** The cookie properties as defined by express-session */ 34 | cookie: { 35 | originalMaxAge: number | null; 36 | maxAge?: number; 37 | signed?: boolean; 38 | expires?: Date | null; 39 | httpOnly?: boolean; 40 | path?: string; 41 | domain?: string; 42 | secure?: boolean | 'auto'; 43 | sameSite?: boolean | 'lax' | 'strict' | 'none'; 44 | } 45 | } 46 | 47 | interface UnsignResult { 48 | valid: boolean; 49 | renew: boolean; 50 | value: string | null; 51 | } 52 | 53 | interface Signer { 54 | sign: (value: string) => string; 55 | unsign: (input: string) => UnsignResult; 56 | } 57 | 58 | declare namespace fastifySession { 59 | 60 | export interface FastifySessionObject extends Fastify.Session { 61 | sessionId: string; 62 | 63 | encryptedSessionId: string; 64 | 65 | /** Updates the `expires` property of the session's cookie. */ 66 | touch(): void; 67 | 68 | /** 69 | * Regenerates the session by generating a new `sessionId`. 70 | * 71 | * ignoreFields specifies which fields should be kept in the new session object. 72 | */ 73 | regenerate(callback: Callback): void; 74 | regenerate(ignoreFields: string[], callback: Callback): void; 75 | regenerate(): Promise; 76 | regenerate(ignoreFields: string[]): Promise; 77 | 78 | /** Set the session cookie options for this request handler. */ 79 | options(opts: Partial): void 80 | 81 | /** Allows to destroy the session in the store. */ 82 | destroy(callback: Callback): void; 83 | destroy(): Promise; 84 | 85 | /** Reloads the session data from the store and re-populates the request.session object. */ 86 | reload(callback: Callback): void; 87 | reload(): Promise; 88 | 89 | /** Save the session back to the store, replacing the contents on the store with the contents in memory. */ 90 | save(callback: Callback): void; 91 | save(): Promise; 92 | 93 | /** sets values in the session. */ 94 | set(key: K, value: Fastify.Session[K]): void; 95 | 96 | /** gets values from the session. */ 97 | get(key: K): Fastify.Session[K] | undefined; 98 | 99 | /** checks if session has been modified since it was generated or loaded from the store. */ 100 | isModified(): boolean; 101 | } 102 | 103 | export interface SessionStore { 104 | set( 105 | sessionId: string, 106 | session: Fastify.Session, 107 | callback: Callback 108 | ): void; 109 | get( 110 | sessionId: string, 111 | callback: CallbackSession 112 | ): void; 113 | destroy(sessionId: string, callback: Callback): void; 114 | } 115 | 116 | export interface FastifySessionOptions { 117 | /** 118 | * The secret used to sign the cookie. 119 | * 120 | * Must be an array of strings, or a string with length 32 or greater. If an array, the first secret is used to 121 | * sign new cookies, and is the first one to be checked for incoming cookies. 122 | * Further secrets in the array are used to check incoming cookies, in the order specified. 123 | * 124 | * Note that the array may be manipulated by the rest of the application during its life cycle. 125 | * This can be done by storing the array in a separate variable that is later manipulated with mutating methods 126 | * like unshift(), pop(), splice(), etc. 127 | * This can be used to rotate the signing secret at regular intervals. 128 | * A secret should remain somewhere in the array as long as there are active sessions with cookies signed by it. 129 | * Secrets management is left up to the rest of the application. 130 | */ 131 | secret: string | string[] | Signer; 132 | 133 | /** 134 | * The algorithm used to sign the cookie. 135 | * 136 | * @default 'sha256' 137 | */ 138 | algorithm?: string; 139 | 140 | /** The name of the session cookie. Defaults to `sessionId`. */ 141 | cookieName?: string; 142 | 143 | /** 144 | * The options object used to generate the `Set-Cookie` header of the session cookie. 145 | * 146 | * @link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie 147 | */ 148 | cookie?: CookieOptions; 149 | 150 | /** 151 | * A session store. 152 | * Compatible to stores from express-session. 153 | * Defaults to a simple in memory store. 154 | * Note: The default store should not be used in a production environment because it will leak memory. 155 | */ 156 | store?: fastifySession.SessionStore; 157 | 158 | /** 159 | * Save sessions to the store, even when they are new and not modified. 160 | * Defaults to true. Setting this to false can be useful to save storage space and to comply with the EU cookie law. 161 | */ 162 | saveUninitialized?: boolean; 163 | 164 | /** 165 | * Force the session identifier cookie to be set on every response. The expiration is reset to the original maxAge, resetting the expiration countdown. 166 | * Defaults to true. This is typically used in conjuction with short, non-session-length maxAge values to provide a quick timeout of the session data with reduced potential of it occurring during on going server interactions. 167 | */ 168 | rolling?: boolean; 169 | 170 | /** Function used to generate new session IDs. */ 171 | idGenerator?(request?: Fastify.FastifyRequest): string; 172 | 173 | /** 174 | * Prefixes all cookie values. Run with "s:" to be be compatible with express-session. 175 | * Defaults to "" 176 | */ 177 | cookiePrefix?: string; 178 | } 179 | 180 | export interface CookieOptions extends Omit { 181 | /** A `number` in milliseconds that specifies the `Expires` attribute by adding the specified milliseconds to the current date. If both `expires` and `maxAge` are set, then `expires` is used. */ 182 | maxAge?: number; 183 | } 184 | 185 | export class MemoryStore implements fastifySession.SessionStore { 186 | constructor (map?: Map) 187 | set ( 188 | sessionId: string, 189 | session: Fastify.Session, 190 | callback: Callback 191 | ): void 192 | get ( 193 | sessionId: string, 194 | callback: CallbackSession 195 | ): void 196 | destroy (sessionId: string, callback: Callback): void 197 | } 198 | 199 | export const Store: MemoryStore 200 | 201 | export const fastifySession: FastifySession 202 | export { fastifySession as default } 203 | } 204 | 205 | declare function fastifySession (...params: Parameters): ReturnType 206 | export = fastifySession 207 | -------------------------------------------------------------------------------- /types/types.test-d.ts: -------------------------------------------------------------------------------- 1 | import MongoStore from 'connect-mongo' 2 | import RedisStore from 'connect-redis' 3 | import fastify, { 4 | FastifyInstance, 5 | FastifyReply, 6 | FastifyRequest, 7 | Session 8 | } from 'fastify' 9 | import Redis from 'ioredis' 10 | import { expectAssignable, expectNotAssignable, expectDocCommentIncludes, expectError, expectType } from 'tsd' 11 | import fastifySession, { CookieOptions, MemoryStore, SessionStore } from '..' 12 | 13 | const plugin = fastifySession 14 | 15 | class EmptyStore { 16 | set (_sessionId: string, _session: any, _callback: Function) {} 17 | 18 | get (_sessionId: string, _callback: Function) {} 19 | 20 | destroy (_sessionId: string, _callback: Function) {} 21 | } 22 | 23 | declare module 'fastify' { 24 | interface Session { 25 | user?: { 26 | id: number; 27 | }; 28 | foo: string 29 | } 30 | } 31 | 32 | expectType(plugin.Store) 33 | expectType(plugin.MemoryStore) 34 | 35 | const secret = 'ABCDEFGHIJKLNMOPQRSTUVWXYZ012345' 36 | 37 | const app: FastifyInstance = fastify() 38 | app.register(plugin, { secret: 'DizIzSecret' }) 39 | app.register(plugin, { secret: 'DizIzSecret', rolling: true }) 40 | app.register(plugin, { 41 | secret, 42 | rolling: false, 43 | cookie: { 44 | secure: false 45 | } 46 | }) 47 | app.register(plugin, { 48 | secret, 49 | cookie: { 50 | maxAge: 1000, 51 | secure: 'auto' 52 | } 53 | }) 54 | 55 | const cookieMaxAge: CookieOptions = {} 56 | expectDocCommentIncludes<'millisecond'>(cookieMaxAge.maxAge) 57 | 58 | app.register(plugin, { 59 | secret, 60 | store: new EmptyStore() 61 | }) 62 | app.register(plugin, { 63 | secret, 64 | store: new RedisStore({ client: new Redis() }) 65 | }) 66 | app.register(plugin, { 67 | secret, 68 | store: MongoStore.create({ mongoUrl: 'mongodb://connection-string' }) 69 | }) 70 | app.register(plugin, { 71 | secret, 72 | store: new MemoryStore(new Map()) 73 | }) 74 | app.register(plugin, { 75 | secret, 76 | idGenerator: () => Date.now() + '' 77 | }) 78 | app.register(plugin, { 79 | secret, 80 | }) 81 | app.register(plugin, { 82 | secret, 83 | idGenerator: (request) => `${request === undefined ? 'null' : request.ip}-${Date.now()}` 84 | }) 85 | 86 | expectError(app.register(plugin)) 87 | expectError(app.register(plugin, {})) 88 | 89 | expectError(app.decryptSession('sessionId', {}, () => ({}))) 90 | app.decryptSession<{ hello: 'world' }>('sessionId', { hello: 'world' }, () => ({})) 91 | app.decryptSession<{ hello: 'world' }>('sessionId', { hello: 'world' }, { domain: '/' }, () => ({})) 92 | app.decryptSession('sessionId', {}, () => ({})) 93 | app.decryptSession('sessionId', {}, { domain: '/' }, () => ({})) 94 | 95 | app.route({ 96 | method: 'GET', 97 | url: '/', 98 | preHandler (req, _rep, next) { 99 | expectType(req.session.destroy(next)) 100 | expectType>(req.session.destroy()) 101 | }, 102 | async handler (request, reply) { 103 | expectType(request) 104 | expectType(reply) 105 | expectType>(request.sessionStore) 106 | expectError((request.sessionStore = null)) 107 | expectError(request.session.doesNotExist()) 108 | expectType<{ id: number } | undefined>(request.session.user) 109 | request.sessionStore.set('session-set-test', request.session, () => {}) 110 | request.sessionStore.get('', (err, session) => { 111 | const store = new MemoryStore() 112 | if (session) store.set('session-set-test', session, () => {}) 113 | expectType(err) 114 | expectType(session) 115 | expectType<{ id: number } | undefined>(session?.user) 116 | }) 117 | expectType(request.session.set('foo', 'bar')) 118 | expectType(request.session.get('foo')) 119 | expectType(request.session.touch()) 120 | expectType(request.session.isModified()) 121 | expectType(request.session.reload(() => {})) 122 | expectType(request.session.destroy(() => {})) 123 | expectType(request.session.regenerate(() => {})) 124 | expectType(request.session.regenerate(['foo'], () => {})) 125 | expectType(request.session.save(() => {})) 126 | expectType>(request.session.reload()) 127 | expectType>(request.session.destroy()) 128 | expectType>(request.session.regenerate()) 129 | expectType>(request.session.regenerate(['foo'])) 130 | expectType>(request.session.save()) 131 | expectError(request.session.options({ keyNotInCookieOptions: true })) 132 | expectError(request.session.options({ signed: true })) 133 | expectType(request.session.options({})) 134 | expectType(request.session.options({ 135 | domain: 'example.com', 136 | expires: new Date(), 137 | httpOnly: true, 138 | maxAge: 1000, 139 | partitioned: true, 140 | path: '/', 141 | sameSite: 'lax', 142 | priority: 'low', 143 | secure: 'auto' 144 | })) 145 | } 146 | }) 147 | 148 | const customSigner = { 149 | sign: (value: string) => value, 150 | unsign: (_input: string) => ({ 151 | valid: true, 152 | renew: false, 153 | value: null 154 | }) 155 | } 156 | 157 | app.register(plugin, { secret: customSigner }) 158 | 159 | const app2 = fastify() 160 | app2.register(fastifySession, { secret: 'DizIzSecret' }) 161 | 162 | app2.get('/', async function (request) { 163 | expectAssignable(request.session.get('foo')) 164 | expectNotAssignable(request.session.get('foo')) 165 | 166 | expectType(request.session.set('foo', 'bar')) 167 | expectError(request.session.set('foo', 2)) 168 | 169 | expectType(request.session.get('user')) 170 | expectAssignable(request.session.set('user', { id: 2 })) 171 | 172 | expectError(request.session.get('not exist')) 173 | expectError(request.session.set('not exist', 'abc')) 174 | 175 | expectType(request.session.get('not exist')) 176 | expectAssignable(request.session.set('not exist', 'abc')) 177 | }) 178 | --------------------------------------------------------------------------------