├── .eslintrc ├── .github └── workflows │ ├── nodejs.yml │ └── release.yml ├── .gitignore ├── .npmrc ├── CHANGELOG.md ├── LICENSE ├── Readme.md ├── __snapshots__ └── index.test.ts.js ├── example.cjs ├── package.json ├── src ├── context.ts ├── index.ts ├── session.ts └── util.ts ├── test ├── context_store.ts ├── contextstore.test.ts ├── cookie.test.ts ├── externalkey.test.ts ├── index.test.ts ├── store.test.ts ├── store.ts ├── store_with_ctx.test.ts └── store_with_ctx.ts └── tsconfig.json /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "eslint-config-egg/typescript", 4 | "eslint-config-egg/lib/rules/enforce-node-prefix" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | Job: 11 | name: Node.js 12 | uses: node-modules/github-actions/.github/workflows/node-test.yml@master 13 | with: 14 | os: 'ubuntu-latest, macos-latest, windows-latest' 15 | version: '18.19.0, 18, 20, 22' 16 | secrets: 17 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 18 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | 7 | jobs: 8 | release: 9 | name: Node.js 10 | uses: koajs/github-actions/.github/workflows/node-release.yml@master 11 | secrets: 12 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 13 | GIT_TOKEN: ${{ secrets.GIT_TOKEN }} 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | *.log 4 | .tshy* 5 | .eslintcache 6 | dist 7 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [7.0.2](https://github.com/koajs/session/compare/v7.0.1...v7.0.2) (2025-01-19) 4 | 5 | 6 | ### Bug Fixes 7 | 8 | * make sure options instance is not mutated ([#230](https://github.com/koajs/session/issues/230)) ([c45bf8b](https://github.com/koajs/session/commit/c45bf8b236f8b79f4d2e4ed40b7fae585f30330b)) 9 | 10 | ## [7.0.1](https://github.com/koajs/session/compare/v7.0.0...v7.0.1) (2025-01-19) 11 | 12 | 13 | ### Bug Fixes 14 | 15 | * remove unused dependencies ([#229](https://github.com/koajs/session/issues/229)) ([3421008](https://github.com/koajs/session/commit/3421008381a3aacf2e43d52314749eb850851193)) 16 | 17 | ## [7.0.0](https://github.com/koajs/session/compare/v6.4.0...v7.0.0) (2025-01-19) 18 | 19 | 20 | ### ⚠ BREAKING CHANGES 21 | 22 | * drop Node.js < 18.19.0 support 23 | 24 | ### Features 25 | 26 | * support cjs and esm both by tshy ([#228](https://github.com/koajs/session/issues/228)) ([575864c](https://github.com/koajs/session/commit/575864c6ae7504da15e72e8112a99757f3eee188)) 27 | 28 | 6.4.0 / 2023-02-04 29 | ================== 30 | 31 | **features** 32 | * [[`4cd3bef`](http://github.com/koajs/session/commit/4cd3bef4fc1900b847d2e133e3b2599a711f1aea)] - feat: Add Session.regenerate() method (#221) (Jürg Lehni <>) 33 | 34 | 6.3.1 / 2023-01-03 35 | ================== 36 | 37 | **fixes** 38 | * [[`e2de39e`](http://github.com/koajs/session/commit/e2de39e6acaf5eef9c8660cbd864ecccaa2b60d0)] - fix: keep crc v3 (#223) (fengmk2 <>) 39 | 40 | 6.3.0 / 2023-01-03 41 | ================== 42 | 43 | **features** 44 | * [[`878669e`](http://github.com/koajs/session/commit/878669ee2c734e3d9902d83f57e21d2113178b79)] - feat: update uuid to v8 (#218) (zhennann <>) 45 | 46 | **others** 47 | * [[`df2d28f`](http://github.com/koajs/session/commit/df2d28ffb177272739964eb5a503f93db870aa28)] - test: run ci on GitHub Action (#222) (fengmk2 <>) 48 | 49 | 6.2.0 / 2021-03-30 50 | ================== 51 | 52 | **features** 53 | * [[`7cde341`](http://github.com/koajs/session/commit/7cde341db1691ee6885eb1b11ff3a3a3632fd5ce)] - feat: add session.externalKey (#207) (Yiyu He <>) 54 | 55 | 6.1.0 / 2020-10-08 56 | ================== 57 | 58 | **features** 59 | * [[`32e3526`](http://github.com/koajs/session/commit/32e352665f2adbcda34d1d990bb6c5d875c0b625)] - feat: add context to external store .get() and .set() options params (#201) (Ngorror <>) 60 | 61 | **others** 62 | * [[`f765595`](http://github.com/koajs/session/commit/f76559568bb7f6321cab3f44ae759521deca3dd1)] - chore: Create LICENSE File (#195) (Dominic Egginton <>) 63 | 6.0.0 / 2020-04-26 64 | ================== 65 | 66 | **fixes** 67 | * [[`d34fc8e`](https://github.com/koajs/session/commit/d34fc8e0395bd3dc0c8cceda4374039a4d414060)] - fix: RFC6265 compliant default cookie name (#197) (zacanger <>) 68 | * [BREAKING CHANGE]: Default cookie is now `koa.sess` rather than `koa:sess` 69 | 70 | 5.13.1 / 2020-02-01 71 | ================== 72 | 73 | **fixes** 74 | * [[`ecd1f5e`](http://github.com/koajs/session/commit/ecd1f5edaa6ff1e77cc461d1107432b394ce21d2)] - fix: don't set any value to sameSite by default (#194) (fengmk2 <>) 75 | 76 | 5.13.0 / 2020-02-01 77 | ================== 78 | 79 | **features** 80 | * [[`cb09a09`](http://github.com/koajs/session/commit/cb09a09cfa4767610d7cc7282a0de2a3a651c6ae)] - feat: support session cookie sameSite options (#193) (fengmk2 <>) 81 | 82 | 5.12.3 / 2019-08-23 83 | ================== 84 | 85 | **fixes** 86 | * [[`909d93f`](http://github.com/koajs/session/commit/909d93fc6b74c6e29b0e83f555f1fc4002a6a108)] - fix: correctly expire cookies for nullified sessions (Justin <>) 87 | 88 | 5.12.2 / 2019-07-10 89 | ================== 90 | 91 | **fixes** 92 | * [[`c23bab4`](http://github.com/koajs/session/commit/c23bab4023b95c65be46b4eeaf089608ddaa738e)] - fix: remvoe unused code (dead-horse <>) 93 | 94 | 5.12.1 / 2019-07-10 95 | ================== 96 | 97 | **fixes** 98 | * [[`77968e3`](http://github.com/koajs/session/commit/77968e3ff6fb5d4f4a36665474ccd992fed689ec)] - fix: ensure ctx.session always has value (dead-horse <>) 99 | 100 | 5.12.0 / 2019-05-17 101 | ================== 102 | 103 | **features** 104 | * [[`39ca830`](http://github.com/koajs/session/commit/39ca830a99ae7fcab2bd499a1f2a87de53fd1944)] - feat: add the parameter "ctx" to the function "genid" so can get the … (#173) (松松 <<1733458402@qq.com>>) 105 | 106 | **others** 107 | * [[`3d57a44`](http://github.com/koajs/session/commit/3d57a443c7e0050d4066c871bf8da2656cda99f1)] - docs: add genid(ctx) in readme (dead-horse <>) 108 | 109 | 5.11.0 / 2019-04-29 110 | ================== 111 | 112 | **features** 113 | * [[`b79134d`](http://github.com/koajs/session/commit/b79134d6854173bf46d6703e79636a58f9282e15)] - feat: make sure session id is global unique (#170) (fengmk2 <>) 114 | 115 | **fixes** 116 | * [[`c2b4259`](http://github.com/koajs/session/commit/c2b4259ccef6095cad2f3ff51968b21cea993d13)] - fix: remove package-lock.json (fengmk2 <>) 117 | 118 | **others** 119 | * [[`23ad871`](http://github.com/koajs/session/commit/23ad8718a9a392c0c563893a10b5ca9f6fd70ebe)] - deps: Fix security vulnerabilities from npm audit (#163) (Douglas Wade <>) 120 | * [[`1600aab`](http://github.com/koajs/session/commit/1600aabdfa6a86973e3fab9f4064c3ed82b10604)] - test: changed "ctx.session is mockable" tests names to more appropriate (#158) (Vitaliy Zaytsev <>) 121 | 122 | 5.10.1 / 2018-12-18 123 | ================== 124 | 125 | **features** 126 | * [[`5f12f70`](http://github.com/koajs/session/commit/5f12f7019b4fbb3ce1d495c1c7fb8a234ae16818)] - feat: allow init multi session middleware (#159) (killa <>) 127 | 128 | **fixes** 129 | * [[`89c048a`](http://github.com/koajs/session/commit/89c048adc5a64b6c12c87047b766ac34be10af77)] - fix: moved "pedding" package to dev dependencies (#155) (Vitaliy Zaytsev <>) 130 | 131 | 5.10.0 / 2018-10-29 132 | ================== 133 | 134 | **features** 135 | * [[`81906f7`](http://github.com/koajs/session/commit/81906f7724ef009dc14686f4990af35c716f6db9)] - feat: support options.externalKey #88 (#149) (Tree Xie <>) 136 | 137 | 5.9.0 / 2018-08-28 138 | ================== 139 | 140 | **features** 141 | * [[`7241400`](http://github.com/koajs/session/commit/724140076b65867b1a0cffee4f061971be8751c0)] - feat: Add autoCommit option (#139) (Jonas Galvez <>) 142 | 143 | 5.8.3 / 2018-08-22 144 | ================== 145 | 146 | **fixes** 147 | * [[`6f1a41c`](http://github.com/koajs/session/commit/6f1a41cf499f55532f0e7ce0de04d778a0466496)] - fix: session not works (#136) (吖猩 <>) 148 | 149 | **others** 150 | * [[`95272ff`](http://github.com/koajs/session/commit/95272ff912af8dd31ae9f038df9540d8b6c019d7)] - fix typo in README.md (#134) (Maples7 <>) 151 | 152 | 5.8.2 / 2018-07-12 153 | ================== 154 | 155 | **fixes** 156 | * [[`c487944`](http://github.com/koajs/session/commit/c487944c22056fdd37433bdeab3d665dbd116744)] - fix: Fixes a bug that reset the cookie expire date to the default (1 day) when using browser sessions (maxAge: 'session') (#117) (Adriano <>) 157 | 158 | **others** 159 | * [[`9050605`](http://github.com/koajs/session/commit/90506055366a31205b0895592eb00d43f8d9da28)] - deps: Upgrade debug@^3.1.0 (#107) (Daniel Tseng <>) 160 | * [[`c48e1e0`](http://github.com/koajs/session/commit/c48e1e054566fe09c81ff50f530c6f230f07c7d5)] - Update Readme.md (#123) (Wellington Soares <>) 161 | 162 | 5.8.1 / 2018-01-17 163 | ================== 164 | 165 | **fixes** 166 | * [[`bdb4fd4`](http://github.com/koajs/session/commit/bdb4fd45a7c247c94f0035585104b004e36ec725)] - fix: ensure store expired after cookie (dead-horse <>) 167 | 168 | 5.8.0 / 2018-01-17 169 | ================== 170 | 171 | **features** 172 | * [[`bb5f4bf`](http://github.com/koajs/session/commit/bb5f4bf86da802cb37cd5e3a990b5bbcc4f6d144)] - feat: support opts.renew (#111) (Yiyu He <>) 173 | 174 | 5.7.1 / 2018-01-11 175 | ================== 176 | 177 | **fixes** 178 | * [[`72fa5fe`](http://github.com/koajs/session/commit/72fa5fec71a8fa3c4e8b75226b401e965d8d31c7)] - fix: emit event in next tick (dead-horse <>) 179 | 180 | 5.7.0 / 2018-01-09 181 | ================== 182 | 183 | **features** 184 | * [[`a2401c8`](http://github.com/koajs/session/commit/a2401c85b486a87a4bf933e457b09088496735d7)] - feat: emit event expose ctx (dead-horse <>) 185 | 186 | 5.6.0 / 2018-01-09 187 | ================== 188 | 189 | **features** 190 | * [[`f00c1ef`](http://github.com/koajs/session/commit/f00c1ef9857fec52e1aaf981ba9a8e837b3e7ffa)] - feat: emit events when session invalid (#108) (Yiyu He <>) 191 | 192 | 5.5.1 / 2017-11-17 193 | ================== 194 | 195 | **others** 196 | * [[`b976b10`](http://github.com/koajs/session/commit/b976b10212f522b675711badb7ce1bc9a909d19d)] - perf: no need to assign opts (#103) (Yiyu He <>) 197 | * [[`c040b59`](http://github.com/koajs/session/commit/c040b5997d35267a3a65becf91e327615ff17fa5)] - chore: fix example bug and use syntactic sugar (#97) (Runrioter Wung <>) 198 | * [[`906277a`](http://github.com/koajs/session/commit/906277a3c9995ed4f07d2cee55e3020af0c75168)] - docs: copyediting (#85) (Nate Silva <>) 199 | 200 | 5.5.0 / 2017-08-04 201 | ================== 202 | 203 | **features** 204 | * [[`ec88cfb`](http://github.com/koajs/session/commit/ec88cfb095ddbfa9a0db465e3f9e459fb6f92bec)] - feat: support options.prefix for external store (#93) (Yiyu He <>) 205 | 206 | 5.4.0 / 2017-07-03 207 | ================== 208 | 209 | * feat: opts.genid (#87) 210 | 211 | 5.3.0 / 2017-06-17 212 | ================== 213 | 214 | * feat: support rolling (#84) 215 | 216 | 5.2.0 / 2017-06-15 217 | ================== 218 | 219 | * feat: support options.ContextStore (#81) 220 | 221 | 5.1.0 / 2017-06-01 222 | ================== 223 | 224 | * Create capability to create cookies that expire when browser is close… (#77) 225 | 226 | 5.0.0 / 2017-03-12 227 | ================== 228 | 229 | * feat: async/await support (#70) 230 | 231 | 4.0.1 / 2017-03-01 232 | ================== 233 | 234 | * fix: ctx.session should be configurable (#67) 235 | 236 | 4.0.0 / 2017-02-27 237 | ================== 238 | 239 | * [BREAKING CHANGE]: Drop support for node < 4. 240 | * [BREAKING CHANGE]: Internal implementations are changed, so some private API is changed. 241 | * Change private api `session.save()`, won't set cookie immediately now. 242 | * Remove private api `session.changed()`. 243 | * Remove undocumented property context.sessionKey, can use opts.key instead. 244 | * Change undocumented property context.sessionOptions to getter. 245 | * feat: Support external store by pass options.store. 246 | * feat: Throw when encode session error, consider a breaking change. 247 | * feat: Clean cookie when decode session throw error, ensure next request won't throw again. 248 | * fix: Customize options.decode will check expired now 249 | * docs: Remove Semantics in README because it's not "guest" sessions any more 250 | 251 | 3.4.0 / 2016-10-15 252 | ================== 253 | 254 | * fix: add 'session' name for middleware function (#58) 255 | * chore(package): update dependencies 256 | * readme: ignore favicon in example 257 | 258 | 3.3.1 / 2015-07-08 259 | ================== 260 | 261 | * code: fix error in variable referencing 262 | 263 | 3.3.0 / 2015-07-07 264 | ================== 265 | 266 | * custom encode/decode support 267 | 268 | 3.2.0 / 2015-06-08 269 | ================== 270 | 271 | * feat: add opts.valid() and opts.beforeSave() hooks 272 | 273 | 3.1.1 / 2015-06-04 274 | ================== 275 | 276 | * deps: upgrade deep-equal to 1.0.0 277 | * fix: allow get session property before enter session middleware 278 | 279 | 3.1.0 / 2014-12-25 280 | ================== 281 | 282 | * add session.maxAge 283 | * set expire in cookie value 284 | 285 | 3.0.0 / 2014-12-11 286 | ================== 287 | 288 | * improve performance by reduce hiddin class on every request 289 | * refactor with commit() helper 290 | * refactor error handling with finally statement 291 | 292 | 2.0.0 / 2014-02-17 293 | ================== 294 | 295 | * changed cookies to be base64-encoded (somewhat breaks backwards compatibility) 296 | 297 | 1.2.1 / 2014-02-04 298 | ================== 299 | 300 | * fix saving sessions when a downstream error is thrown 301 | 302 | 1.2.0 / 2013-12-21 303 | ================== 304 | 305 | * remove sid from docs 306 | * remove uid2 dep 307 | * change: only save new sessions if populated 308 | * update to use new middleware signature 309 | 310 | 1.1.0 / 2013-11-15 311 | ================== 312 | 313 | * add change check, removing the need for `.save()` 314 | * add sane defaults. Closes #4 315 | * add session clearing support. Closes #9 316 | * remove public `.save()` 317 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2014-present Koajs contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # koa-session 2 | 3 | [![NPM version][npm-image]][npm-url] 4 | [![Node.js CI](https://github.com/koajs/session/actions/workflows/nodejs.yml/badge.svg)](https://github.com/koajs/session/actions/workflows/nodejs.yml) 5 | [![Test coverage][codecov-image]][codecov-url] 6 | [![Known Vulnerabilities][snyk-image]][snyk-url] 7 | [![npm download][download-image]][download-url] 8 | [![Node.js Version](https://img.shields.io/node/v/koajs/session.svg?style=flat)](https://nodejs.org/en/download/) 9 | [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](https://makeapullrequest.com) 10 | 11 | [npm-image]: https://img.shields.io/npm/v/koa-session.svg?style=flat-square 12 | [npm-url]: https://npmjs.org/package/koa-session 13 | [codecov-image]: https://codecov.io/gh/koajs/session/branch/master/graph/badge.svg 14 | [codecov-url]: https://codecov.io/gh/koajs/session 15 | [snyk-image]: https://snyk.io/test/npm/koa-session/badge.svg?style=flat-square 16 | [snyk-url]: https://snyk.io/test/npm/koa-session 17 | [download-image]: https://img.shields.io/npm/dm/koa-session.svg?style=flat-square 18 | [download-url]: https://npmjs.org/package/koa-session 19 | 20 | Simple session middleware for Koa. Defaults to cookie-based sessions and supports external stores. 21 | 22 | ## Installation 23 | 24 | ```bash 25 | npm install koa-session 26 | ``` 27 | 28 | ## Notice 29 | 30 | 7.x has a breaking change: drop Node.js < 18.19.0 support. And it support CommonJS and ESM both. 31 | 32 | 6.x changed the default cookie key from `koa:sess` to `koa.sess` to ensure `set-cookie` value valid with HTTP spec. 33 | [See issue](https://github.com/koajs/session/issues/28). 34 | If you want to be compatible with the previous version, you can manually set `config.key` to `koa:sess`. 35 | 36 | ## Example 37 | 38 | View counter example: 39 | 40 | ```js 41 | import Koa from 'koa'; 42 | import session from 'koa-session'; 43 | 44 | const app = new Koa(); 45 | 46 | app.keys = ['some secret hurr']; 47 | 48 | const CONFIG = { 49 | key: 'koa.sess', /** (string) cookie key (default is koa.sess) */ 50 | /** (number || 'session') maxAge in ms (default is 1 days) */ 51 | /** 'session' will result in a cookie that expires when session/browser is closed */ 52 | /** Warning: If a session cookie is stolen, this cookie will never expire */ 53 | maxAge: 86400000, 54 | autoCommit: true, /** (boolean) automatically commit headers (default true) */ 55 | overwrite: true, /** (boolean) can overwrite or not (default true) */ 56 | httpOnly: true, /** (boolean) httpOnly or not (default true) */ 57 | signed: true, /** (boolean) signed or not (default true) */ 58 | rolling: false, /** (boolean) Force a session identifier cookie to be set on every response. The expiration is reset to the original maxAge, resetting the expiration countdown. (default is false) */ 59 | renew: false, /** (boolean) renew session when session is nearly expired, so we can always keep user logged in. (default is false)*/ 60 | secure: true, /** (boolean) secure cookie*/ 61 | sameSite: null, /** (string) session cookie sameSite options (default null, do not provide this key if you are not restricting sameSite) */ 62 | }; 63 | 64 | app.use(session(CONFIG, app)); 65 | // or if you prefer all default config, just use => app.use(session(app)); 66 | 67 | app.use(ctx => { 68 | // ignore favicon 69 | if (ctx.path === '/favicon.ico') return; 70 | 71 | let n = ctx.session.views || 0; 72 | ctx.session.views = ++n; 73 | ctx.body = n + ' views'; 74 | }); 75 | 76 | app.listen(3000); 77 | console.log('listening on port 3000'); 78 | ``` 79 | 80 | ## API 81 | 82 | ### Options 83 | 84 | The cookie name is controlled by the `key` option, which defaults 85 | to "koa.sess". All other options are passed to `ctx.cookies.get()` and 86 | `ctx.cookies.set()` allowing you to control security, domain, path, 87 | and signing among other settings. 88 | 89 | #### Custom `encode/decode` Support 90 | 91 | Use `options.encode` and `options.decode` to customize your own encode/decode methods. 92 | 93 | ### Hooks 94 | 95 | - `valid()`: valid session value before use it 96 | - `beforeSave()`: hook before save session 97 | 98 | ### External Session Stores 99 | 100 | The session is stored in a cookie by default, but it has some disadvantages: 101 | 102 | - Session is stored on client side unencrypted 103 | - [Browser cookies always have length limits](http://browsercookielimits.squawky.net/) 104 | 105 | You can store the session content in external stores (Redis, MongoDB or other DBs) by passing `options.store` with three methods (these need to be async functions): 106 | 107 | - `get(key, maxAge, { rolling, ctx })`: get session object by key 108 | - `set(key, sess, maxAge, { rolling, changed, ctx })`: set session object for key, with a `maxAge` (in ms) 109 | - `destroy(key, {ctx})`: destroy session for key 110 | 111 | Once you pass `options.store`, session storage is dependent on your external store -- you can't access the session if your external store is down. **Use external session stores only if necessary, avoid using session as a cache, keep the session lean, and store it in a cookie if possible!** 112 | 113 | The way of generating external session id is controlled by the `options.genid(ctx)`, which defaults to `uuid.v4()`. 114 | 115 | If you want to add prefix for all external session id, you can use `options.prefix`, it will not work if `options.genid(ctx)` present. 116 | 117 | If your session store requires data or utilities from context, `opts.ContextStore` is also supported. `ContextStore` must be a class which claims three instance methods demonstrated above. `new ContextStore(ctx)` will be executed on every request. 118 | 119 | ### Events 120 | 121 | `koa-session` will emit event on `app` when session expired or invalid: 122 | 123 | - `session:missed`: can't get session value from external store. 124 | - `session:invalid`: session value is invalid. 125 | - `session:expired`: session value is expired. 126 | 127 | ### Custom External Key 128 | 129 | External key is used the cookie by default, but you can use `options.externalKey` to customize your own external key methods. `options.externalKey` with two methods: 130 | 131 | - `get(ctx)`: get the external key 132 | - `set(ctx, value)`: set the external key 133 | 134 | ### Session#isNew 135 | 136 | Returns **true** if the session is new. 137 | 138 | ```js 139 | if (this.session.isNew) { 140 | // user has not logged in 141 | } else { 142 | // user has already logged in 143 | } 144 | ``` 145 | 146 | ### Session#maxAge 147 | 148 | Get cookie's maxAge. 149 | 150 | ### Session#maxAge= 151 | 152 | Set cookie's maxAge. 153 | 154 | ### Session#externalKey 155 | 156 | Get session external key, only exist when external session store present. 157 | 158 | ### Session#save() 159 | 160 | Save this session no matter whether it is populated. 161 | 162 | ### Session#manuallyCommit() 163 | 164 | Session headers are auto committed by default. Use this if `autoCommit` is set to `false`. 165 | 166 | ### Destroying a session 167 | 168 | To destroy a session simply set it to `null`: 169 | 170 | ```js 171 | this.session = null; 172 | ``` 173 | 174 | ## License 175 | 176 | [MIT](LICENSE) 177 | 178 | ## Contributors 179 | 180 | [![Contributors](https://contrib.rocks/image?repo=koajs/session)](https://github.com/koajs/session/graphs/contributors) 181 | 182 | Made with [contributors-img](https://contrib.rocks). 183 | -------------------------------------------------------------------------------- /__snapshots__/index.test.ts.js: -------------------------------------------------------------------------------- 1 | exports['test/index.test.ts SessionOptions schema should have a valid schema 1'] = { 2 | "key": "koa.sess", 3 | "autoCommit": true, 4 | "overwrite": true, 5 | "httpOnly": true, 6 | "signed": true, 7 | "rolling": false, 8 | "renew": false 9 | } 10 | -------------------------------------------------------------------------------- /example.cjs: -------------------------------------------------------------------------------- 1 | 2 | const Koa = require('koa'); 3 | const { createSession } = require('./'); 4 | 5 | const app = new Koa(); 6 | 7 | app.keys = [ 'some secret hurr' ]; 8 | 9 | app.use(createSession(app)); 10 | 11 | app.use(async (ctx, next) => { 12 | if (ctx.path === '/favicon.ico') return next(); 13 | 14 | let n = ctx.session.views || 0; 15 | ctx.session.views = ++n; 16 | ctx.body = n + ' views'; 17 | }); 18 | 19 | app.listen(3000); 20 | console.log('listening on port http://localhost:3000'); 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "koa-session", 3 | "description": "Koa cookie session middleware with external store support", 4 | "repository": { 5 | "type": "git", 6 | "url": "git@github.com:koajs/session.git" 7 | }, 8 | "version": "7.0.2", 9 | "keywords": [ 10 | "koa", 11 | "middleware", 12 | "session", 13 | "cookie" 14 | ], 15 | "devDependencies": { 16 | "@arethetypeswrong/cli": "^0.17.1", 17 | "@eggjs/bin": "7", 18 | "@eggjs/supertest": "8", 19 | "@eggjs/tsconfig": "1", 20 | "@types/crc": "^3.8.3", 21 | "@types/koa": "^2.15.0", 22 | "@types/mocha": "10", 23 | "@types/node": "22", 24 | "eslint": "8", 25 | "eslint-config-egg": "14", 26 | "koa": "2", 27 | "mm": "4", 28 | "rimraf": "6", 29 | "snap-shot-it": "^7.9.10", 30 | "tshy": "3", 31 | "tshy-after": "1", 32 | "typescript": "5" 33 | }, 34 | "license": "MIT", 35 | "dependencies": { 36 | "crc": "^3.8.0", 37 | "is-type-of": "^2.2.0", 38 | "zod": "^3.24.1" 39 | }, 40 | "engines": { 41 | "node": ">= 18.19.0" 42 | }, 43 | "scripts": { 44 | "lint": "eslint --cache src test --ext .ts", 45 | "pretest": "npm run clean && npm run lint -- --fix", 46 | "test": "egg-bin test", 47 | "preci": "npm run clean && npm run lint", 48 | "ci": "egg-bin cov", 49 | "postci": "npm run prepublishOnly && npm run clean", 50 | "clean": "rimraf dist", 51 | "prepublishOnly": "tshy && tshy-after && attw --pack" 52 | }, 53 | "type": "module", 54 | "tshy": { 55 | "exports": { 56 | ".": "./src/index.ts", 57 | "./package.json": "./package.json" 58 | } 59 | }, 60 | "exports": { 61 | ".": { 62 | "import": { 63 | "types": "./dist/esm/index.d.ts", 64 | "default": "./dist/esm/index.js" 65 | }, 66 | "require": { 67 | "types": "./dist/commonjs/index.d.ts", 68 | "default": "./dist/commonjs/index.js" 69 | } 70 | }, 71 | "./package.json": "./package.json" 72 | }, 73 | "files": [ 74 | "dist", 75 | "src" 76 | ], 77 | "types": "./dist/commonjs/index.d.ts", 78 | "main": "./dist/commonjs/index.js", 79 | "module": "./dist/esm/index.js" 80 | } 81 | -------------------------------------------------------------------------------- /src/context.ts: -------------------------------------------------------------------------------- 1 | import { debuglog } from 'node:util'; 2 | import { Session } from './session.js'; 3 | import util from './util.js'; 4 | import type { SessionOptions } from './index.js'; 5 | 6 | const debug = debuglog('koa-session:context'); 7 | 8 | const COOKIE_EXP_DATE = new Date(util.CookieDateEpoch); 9 | const ONE_DAY = 24 * 60 * 60 * 1000; 10 | 11 | export class ContextSession { 12 | ctx: any; 13 | app: any; 14 | opts: SessionOptions; 15 | store: SessionOptions['store']; 16 | session: Session | false; 17 | externalKey?: string; 18 | prevHash?: number; 19 | 20 | /** 21 | * context session constructor 22 | */ 23 | constructor(ctx: any, opts: SessionOptions) { 24 | this.ctx = ctx; 25 | this.app = ctx.app; 26 | this.opts = { ...opts }; 27 | this.store = this.opts.ContextStore ? new this.opts.ContextStore(ctx) : this.opts.store; 28 | } 29 | 30 | /** 31 | * internal logic of `ctx.session` 32 | * @return {Session} session object 33 | */ 34 | get(): Session | null { 35 | // already retrieved 36 | if (this.session) return this.session; 37 | // unset 38 | if (this.session === false) return null; 39 | 40 | // create an empty session or init from cookie 41 | this.store ? this.create() : this.initFromCookie(); 42 | return this.session as Session; 43 | } 44 | 45 | /** 46 | * internal logic of `ctx.session=` 47 | * @param {Object} val session object 48 | */ 49 | set(val: Record | null) { 50 | if (val === null) { 51 | this.session = false; 52 | return; 53 | } 54 | if (typeof val === 'object') { 55 | // use the original `externalKey` if exists to avoid waste storage 56 | this.create(val, this.externalKey); 57 | return; 58 | } 59 | throw new Error('this.session can only be set as null or an object.'); 60 | } 61 | 62 | /** 63 | * init session from external store 64 | * will be called in the front of session middleware 65 | * 66 | * @public 67 | */ 68 | 69 | async initFromExternal() { 70 | debug('init from external'); 71 | const ctx = this.ctx; 72 | const opts = this.opts; 73 | 74 | let externalKey; 75 | if (opts.externalKey) { 76 | externalKey = opts.externalKey.get(ctx); 77 | debug('get external key from custom %s', externalKey); 78 | } else { 79 | externalKey = ctx.cookies.get(opts.key, opts); 80 | debug('get external key from cookie %s', externalKey); 81 | } 82 | 83 | 84 | if (!externalKey) { 85 | // create a new `externalKey` 86 | this.create(); 87 | return; 88 | } 89 | 90 | const sessionData = await this.store!.get(externalKey, opts.maxAge as number, { ctx, rolling: opts.rolling }); 91 | if (!this.valid(sessionData, externalKey)) { 92 | debug('invalid session data, create a new session'); 93 | // create a new `externalKey` 94 | this.create(); 95 | return; 96 | } 97 | 98 | // create with original `externalKey` 99 | this.create(sessionData, externalKey); 100 | this.prevHash = util.hash((this.session as Session).toJSON()); 101 | } 102 | 103 | /** 104 | * init session from cookie 105 | * @private 106 | */ 107 | initFromCookie() { 108 | debug('init from cookie'); 109 | const ctx = this.ctx; 110 | const opts = this.opts; 111 | 112 | const cookie = ctx.cookies.get(opts.key, opts); 113 | if (!cookie) { 114 | this.create(); 115 | return; 116 | } 117 | 118 | let sessionData: Record; 119 | debug('parse cookie: %j', cookie); 120 | try { 121 | sessionData = opts.decode(cookie); 122 | } catch (err: unknown) { 123 | // backwards compatibility: 124 | // create a new session if parsing fails. 125 | // `Buffer.from(string, 'base64')` does not seem to crash 126 | // when `string` is not base64-encoded. 127 | // but `JSON.parse(string)` will crash. 128 | debug('decode %j error: %s', cookie, err); 129 | if (err instanceof Error && !(err instanceof SyntaxError)) { 130 | // clean this cookie to ensure next request won't throw again 131 | ctx.cookies.set(opts.key, '', opts); 132 | // `ctx.onerror` will unset all headers, and set those specified in err 133 | Reflect.set(err, 'headers', { 134 | 'set-cookie': ctx.response.get('set-cookie'), 135 | }); 136 | throw err; 137 | } 138 | this.create(); 139 | return; 140 | } 141 | 142 | debug('parsed session data: %j', sessionData); 143 | if (!this.valid(sessionData)) { 144 | // create a new session if the session data is invalid 145 | this.create(); 146 | debug('invalid session data, create a new session'); 147 | return; 148 | } 149 | 150 | // support access `ctx.session` before session middleware 151 | this.create(sessionData); 152 | this.prevHash = util.hash((this.session as Session).toJSON()); 153 | } 154 | 155 | /** 156 | * verify session(expired or custom verification) 157 | * @param {Object} sessionData session data 158 | * @param {Object} [key] session externalKey(optional) 159 | * @private 160 | */ 161 | protected valid(sessionData: Record, key?: string) { 162 | const ctx = this.ctx; 163 | if (!sessionData) { 164 | this.emit('missed', { key, value: sessionData, ctx }); 165 | return false; 166 | } 167 | 168 | if (typeof sessionData._expire === 'number' && sessionData._expire < Date.now()) { 169 | debug('expired session'); 170 | this.emit('expired', { key, value: sessionData, ctx }); 171 | return false; 172 | } 173 | 174 | const valid = this.opts.valid; 175 | if (typeof valid === 'function' && !valid(ctx, sessionData)) { 176 | // valid session value fail, ignore this session 177 | debug('invalid session'); 178 | this.emit('invalid', { key, value: sessionData, ctx }); 179 | return false; 180 | } 181 | return true; 182 | } 183 | 184 | /** 185 | * @param {String} event event name 186 | * @param {Object} data event data 187 | * @private 188 | */ 189 | emit(event: string, data: unknown) { 190 | setImmediate(() => { 191 | this.app.emit(`session:${event}`, data); 192 | }); 193 | } 194 | 195 | /** 196 | * create a new session and attach to ctx.sess 197 | * 198 | * @param {Object} [sessionData] session data 199 | * @param {String} [externalKey] session external key 200 | */ 201 | protected create(sessionData?: Record, externalKey?: string) { 202 | debug('create session with data: %j, externalKey: %s', sessionData, externalKey); 203 | if (this.store) { 204 | this.externalKey = externalKey ?? this.opts.genid?.(this.ctx); 205 | } 206 | this.session = new Session(this, sessionData, this.externalKey); 207 | } 208 | 209 | /** 210 | * Commit the session changes or removal. 211 | */ 212 | async commit({ save = false, regenerate = false } = {}) { 213 | const session = this.session; 214 | const opts = this.opts; 215 | const ctx = this.ctx; 216 | 217 | // not accessed 218 | if (session === undefined) { 219 | return; 220 | } 221 | 222 | // removed 223 | if (session === false) { 224 | await this.remove(); 225 | return; 226 | } 227 | 228 | if (regenerate) { 229 | await this.remove(); 230 | if (this.store) { 231 | this.externalKey = opts.genid?.(ctx); 232 | } 233 | } 234 | 235 | // force save session when `session._requireSave` set 236 | const reason = save || regenerate || session._requireSave ? 'force' : this._shouldSaveSession(); 237 | debug('should save session: %j', reason); 238 | if (!reason) { 239 | return; 240 | } 241 | 242 | if (typeof opts.beforeSave === 'function') { 243 | debug('before save'); 244 | opts.beforeSave(ctx, session); 245 | } 246 | const changed = reason === 'changed'; 247 | await this.save(changed); 248 | } 249 | 250 | _shouldSaveSession() { 251 | const prevHash = this.prevHash; 252 | const session = this.session as Session; 253 | 254 | // do nothing if new and not populated 255 | const sessionData = session.toJSON(); 256 | if (!prevHash && !Object.keys(sessionData).length) { 257 | return ''; 258 | } 259 | 260 | // save if session changed 261 | const changed = prevHash !== util.hash(sessionData); 262 | if (changed) { 263 | return 'changed'; 264 | } 265 | 266 | // save if opts.rolling set 267 | if (this.opts.rolling) { 268 | return 'rolling'; 269 | } 270 | 271 | // save if opts.renew and session will expired 272 | if (this.opts.renew) { 273 | const expire = session._expire; 274 | const maxAge = session.maxAge; 275 | // renew when session will expired in maxAge / 2 276 | if (expire && maxAge && expire - Date.now() < maxAge / 2) { 277 | return 'renew'; 278 | } 279 | } 280 | 281 | // don't save 282 | return ''; 283 | } 284 | 285 | /** 286 | * remove session 287 | * @private 288 | */ 289 | async remove() { 290 | // Override the default options so that we can properly expire the session cookies 291 | const opts = { 292 | ...this.opts, 293 | expires: COOKIE_EXP_DATE, 294 | maxAge: false, 295 | }; 296 | const ctx = this.ctx; 297 | const key = opts.key; 298 | const externalKey = this.externalKey; 299 | 300 | if (externalKey) { 301 | await this.store!.destroy(externalKey, { ctx }); 302 | } 303 | ctx.cookies.set(key, '', opts); 304 | } 305 | 306 | /** 307 | * save session 308 | * @private 309 | */ 310 | async save(changed: boolean) { 311 | const opts = this.opts; 312 | const key = opts.key; 313 | const externalKey = this.externalKey; 314 | const sessionData = (this.session as Session).toJSON(); 315 | // set expire for check 316 | let maxAge = opts.maxAge ? opts.maxAge : ONE_DAY; 317 | if (maxAge === 'session') { 318 | // do not set _expire in json if maxAge is set to 'session' 319 | // also delete maxAge from options 320 | opts.maxAge = undefined; 321 | sessionData._session = true; 322 | } else { 323 | // set expire for check 324 | sessionData._expire = maxAge + Date.now(); 325 | sessionData._maxAge = maxAge; 326 | } 327 | 328 | // save to external store 329 | if (externalKey) { 330 | debug('save %j to external key %s', sessionData, externalKey); 331 | if (typeof maxAge === 'number') { 332 | // ensure store expired after cookie 333 | maxAge += 10000; 334 | } 335 | await this.store!.set(externalKey, sessionData, maxAge as number, { 336 | changed, 337 | ctx: this.ctx, 338 | rolling: opts.rolling, 339 | }); 340 | if (opts.externalKey) { 341 | opts.externalKey.set(this.ctx, externalKey); 342 | } else { 343 | this.ctx.cookies.set(key, externalKey, opts); 344 | } 345 | return; 346 | } 347 | 348 | // save to cookie with base64 encode string 349 | debug('save session data %j to cookie', sessionData); 350 | const base64String = opts.encode(sessionData); 351 | debug('save session data json base64 format: %s to cookie key: %s with options: %j', 352 | base64String, key, opts); 353 | this.ctx.cookies.set(key, base64String, opts); 354 | } 355 | } 356 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert'; 2 | import { debuglog } from 'node:util'; 3 | import { randomUUID } from 'node:crypto'; 4 | import { isClass } from 'is-type-of'; 5 | import z from 'zod'; 6 | import { ContextSession } from './context.js'; 7 | import util from './util.js'; 8 | 9 | const debug = debuglog('koa-session'); 10 | 11 | const GET_CONTEXT_SESSION = Symbol('get contextSession'); 12 | const CONTEXT_SESSION_INSTANCE = Symbol('contextSession instance'); 13 | 14 | export const SessionOptions = z.object({ 15 | /** 16 | * cookie key 17 | * Default is `koa.sess` 18 | */ 19 | key: z.string().default('koa.sess'), 20 | /** 21 | * maxAge in ms 22 | * Default is `86400000`, one day 23 | * If set to 'session' will result in a cookie that expires when session/browser is closed 24 | * 25 | * Warning: If a session cookie is stolen, this cookie will never expire 26 | */ 27 | maxAge: z.union([ z.number(), z.literal('session') ]).optional(), 28 | /** 29 | * automatically commit headers 30 | * Default is `true` 31 | */ 32 | autoCommit: z.boolean().default(true), 33 | /** 34 | * cookie value can overwrite or not 35 | * Default is `true` 36 | */ 37 | overwrite: z.boolean().default(true), 38 | /** 39 | * httpOnly or not 40 | * Default is `true` 41 | */ 42 | httpOnly: z.boolean().default(true), 43 | /** 44 | * signed or not 45 | * Default is `true` 46 | */ 47 | signed: z.boolean().default(true), 48 | /** 49 | * Force a session identifier cookie to be set on every response. 50 | * The expiration is reset to the original `maxAge`, resetting the expiration countdown. 51 | * Default is `false` 52 | */ 53 | rolling: z.boolean().default(false), 54 | /** 55 | * renew session when session is nearly expired, so we can always keep user logged in. 56 | * Default is `false` 57 | */ 58 | renew: z.boolean().default(false), 59 | /** 60 | * secure cookie 61 | * Default is `undefined`, will be set to `true` if the connection is over HTTPS, otherwise `false`. 62 | */ 63 | secure: z.boolean().optional(), 64 | /** 65 | * session cookie sameSite options 66 | * Default is `undefined`, meaning don't set it 67 | */ 68 | sameSite: z.string().optional(), 69 | /** 70 | * External key is used the cookie by default, 71 | * but you can use `options.externalKey` to customize your own external key methods. 72 | */ 73 | externalKey: z.object({ 74 | /** 75 | * get the external key 76 | * `(ctx) => string` 77 | */ 78 | get: z.function() 79 | .args(z.any()) 80 | .returns(z.string()), 81 | /** 82 | * set the external key 83 | * `(ctx, key) => void` 84 | */ 85 | set: z.function() 86 | .args(z.any(), z.string()) 87 | .returns(z.void()), 88 | }).optional(), 89 | /** 90 | * session storage is dependent on your external store 91 | */ 92 | store: z.object({ 93 | /** 94 | * get session data by key 95 | * `(key, maxAge, { rolling, ctx }) => sessionData | Promise` 96 | */ 97 | get: z.function() 98 | .args(z.string(), z.number(), z.object({ rolling: z.boolean(), ctx: z.any() })) 99 | .returns(z.promise(z.any())), 100 | /** 101 | * set session data for key, with a `maxAge` (in ms) 102 | * `(key, sess, maxAge, { rolling, changed, ctx }) => void | Promise` 103 | */ 104 | set: z.function() 105 | .args(z.string(), z.any(), z.number(), z.object({ rolling: z.boolean(), changed: z.boolean(), ctx: z.any() })) 106 | .returns(z.promise(z.void())), 107 | /** 108 | * destroy session data for key 109 | * `(key, { ctx })=> void | Promise` 110 | */ 111 | destroy: z.function() 112 | .args(z.string(), z.object({ ctx: z.any() })) 113 | .returns(z.promise(z.void())), 114 | }).optional(), 115 | /** 116 | * If your session store requires data or utilities from context, `opts.ContextStore` is also supported. 117 | * `ContextStore` must be a class which claims three instance methods demonstrated above. 118 | * `new ContextStore(ctx)` will be executed on every request. 119 | */ 120 | ContextStore: z.any().optional(), 121 | encode: z.function() 122 | .args(z.any()) 123 | .returns(z.string()) 124 | .optional() 125 | .default(() => util.encode), 126 | decode: z.function() 127 | .args(z.string()) 128 | .returns(z.any()) 129 | .default(() => util.decode), 130 | /** 131 | * If you want to generate a new session id, you can use `genid` option to customize it. 132 | * Default is a function that uses `randomUUID()`. 133 | * `(ctx) => string` 134 | */ 135 | genid: z.function() 136 | .args(z.any()) 137 | .returns(z.string()) 138 | .optional(), 139 | /** 140 | * If you want to prefix the session id, you can use `prefix` option to customize it. 141 | * It will not work if `options.genid(ctx)` present. 142 | */ 143 | prefix: z.string().optional(), 144 | /** 145 | * valid session value before use it 146 | * `(ctx, sessionData) => boolean` 147 | */ 148 | valid: z.function() 149 | .args(z.any(), z.any()) 150 | .returns(z.any()) 151 | .optional(), 152 | /** 153 | * hook before save session 154 | * `(ctx, sessionModel) => void` 155 | */ 156 | beforeSave: z.function() 157 | .args(z.any(), z.any()) 158 | .returns(z.void()) 159 | .optional(), 160 | }); 161 | 162 | const DEFAULT_SESSION_OPTIONS = SessionOptions.parse({}); 163 | 164 | export type SessionOptions = z.infer; 165 | export type CreateSessionOptions = Partial; 166 | 167 | type Middleware = (ctx: any, next: any) => Promise; 168 | 169 | /** 170 | * Initialize session middleware with `opts`: 171 | * 172 | * - `key` session cookie name ["koa.sess"] 173 | * - all other options are passed as cookie options 174 | * 175 | * @param {Object} [opts] session options 176 | * @param {Application} app koa application instance 177 | * @public 178 | */ 179 | export function createSession(opts: CreateSessionOptions, app: any): Middleware; 180 | export function createSession(app: any, opts?: CreateSessionOptions): Middleware; 181 | export function createSession(opts: CreateSessionOptions | any, app: any): Middleware { 182 | // session(app[, opts]) 183 | if (opts && 'use' in opts && typeof opts.use === 'function') { 184 | [ app, opts ] = [ opts, app ]; 185 | } 186 | // app required 187 | if (typeof app?.use !== 'function') { 188 | throw new TypeError('app instance required: `session(opts, app)`'); 189 | } 190 | 191 | const options: SessionOptions = opts ?? {}; 192 | 193 | // back-compat maxage 194 | if (!('maxAge' in options) && 'maxage' in options) { 195 | Reflect.set(options, 'maxAge', Reflect.get(options, 'maxage')); 196 | if (process.env.NODE_ENV !== 'production') { 197 | console.warn('[koa-session] DeprecationWarning: `maxage` option has been renamed to `maxAge`'); 198 | } 199 | } 200 | 201 | // keep backwards compatibility: make sure options instance is not mutated 202 | Object.assign(options, { 203 | ...DEFAULT_SESSION_OPTIONS, 204 | ...options, 205 | }); 206 | SessionOptions.parse(options); 207 | formatOptions(options); 208 | extendContext(app.context, options); 209 | 210 | return async function session(ctx: any, next: any) { 211 | const sess = ctx[GET_CONTEXT_SESSION]; 212 | if (sess.store) { 213 | await sess.initFromExternal(); 214 | } 215 | try { 216 | await next(); 217 | } catch (err) { 218 | throw err; 219 | } finally { 220 | if (options.autoCommit) { 221 | await sess.commit(); 222 | } 223 | } 224 | }; 225 | } 226 | 227 | // Usage: `import session from 'koa-session'` 228 | export default createSession; 229 | 230 | /** 231 | * format and check session options 232 | */ 233 | function formatOptions(opts: SessionOptions) { 234 | // defaults 235 | if (opts.overwrite == null) opts.overwrite = true; 236 | if (opts.httpOnly == null) opts.httpOnly = true; 237 | // delete null sameSite config 238 | if (opts.sameSite == null) delete opts.sameSite; 239 | if (opts.signed == null) opts.signed = true; 240 | if (opts.autoCommit == null) opts.autoCommit = true; 241 | 242 | debug('session options %j', opts); 243 | const store = opts.store; 244 | if (store) { 245 | assert(typeof store.get === 'function', 'store.get must be function'); 246 | assert(typeof store.set === 'function', 'store.set must be function'); 247 | assert(typeof store.destroy === 'function', 'store.destroy must be function'); 248 | } 249 | 250 | const externalKey = opts.externalKey; 251 | if (externalKey) { 252 | assert(typeof externalKey.get === 'function', 'externalKey.get must be function'); 253 | assert(typeof externalKey.set === 'function', 'externalKey.set must be function'); 254 | } 255 | 256 | const ContextStore = opts.ContextStore; 257 | if (ContextStore) { 258 | assert(isClass(ContextStore), 'ContextStore must be a class'); 259 | assert(typeof ContextStore.prototype.get === 'function', 'ContextStore.prototype.get must be function'); 260 | assert(typeof ContextStore.prototype.set === 'function', 'ContextStore.prototype.set must be function'); 261 | assert(typeof ContextStore.prototype.destroy === 'function', 'ContextStore.prototype.destroy must be function'); 262 | } 263 | 264 | if (!opts.genid) { 265 | if (opts.prefix) { 266 | opts.genid = () => `${opts.prefix}${randomUUID()}`; 267 | } else { 268 | opts.genid = () => randomUUID(); 269 | } 270 | } 271 | } 272 | 273 | /** 274 | * extend context prototype, add session properties 275 | * 276 | * @param {Object} context koa's context prototype 277 | * @param {Object} opts session options 278 | */ 279 | function extendContext(context: object, opts: SessionOptions) { 280 | if (context.hasOwnProperty(GET_CONTEXT_SESSION)) { 281 | return; 282 | } 283 | Object.defineProperties(context, { 284 | [GET_CONTEXT_SESSION]: { 285 | get() { 286 | if (this[CONTEXT_SESSION_INSTANCE]) { 287 | return this[CONTEXT_SESSION_INSTANCE]; 288 | } 289 | this[CONTEXT_SESSION_INSTANCE] = new ContextSession(this, opts); 290 | return this[CONTEXT_SESSION_INSTANCE]; 291 | }, 292 | }, 293 | session: { 294 | get() { 295 | return this[GET_CONTEXT_SESSION].get(); 296 | }, 297 | set(val) { 298 | this[GET_CONTEXT_SESSION].set(val); 299 | }, 300 | configurable: true, 301 | }, 302 | sessionOptions: { 303 | get() { 304 | return this[GET_CONTEXT_SESSION].opts; 305 | }, 306 | }, 307 | }); 308 | } 309 | -------------------------------------------------------------------------------- /src/session.ts: -------------------------------------------------------------------------------- 1 | import { inspect } from 'node:util'; 2 | import type { ContextSession } from './context.js'; 3 | 4 | type Callback = (err?: Error) => void; 5 | 6 | /** 7 | * Session model 8 | */ 9 | export class Session { 10 | #sessCtx: ContextSession; 11 | #ctx: any; 12 | #externalKey?: string; 13 | isNew = false; 14 | _requireSave = false; 15 | // session expire time, will be set from sessionData 16 | _expire?: number; 17 | 18 | constructor(sessionContext: ContextSession, sessionData?: Record, externalKey?: string) { 19 | this.#sessCtx = sessionContext; 20 | this.#ctx = sessionContext.ctx; 21 | this.#externalKey = externalKey; 22 | if (!sessionData) { 23 | this.isNew = true; 24 | } else { 25 | for (const k in sessionData) { 26 | // restore maxAge from store 27 | if (k === '_maxAge') { 28 | this.#ctx.sessionOptions.maxAge = sessionData._maxAge; 29 | } else if (k === '_session') { 30 | // set maxAge to 'session' if it's a session lifetime 31 | this.#ctx.sessionOptions.maxAge = 'session'; 32 | } else { 33 | Reflect.set(this, k, sessionData[k]); 34 | } 35 | } 36 | } 37 | } 38 | 39 | /** 40 | * JSON representation of the session. 41 | */ 42 | toJSON() { 43 | const obj: Record = {}; 44 | for (const key in this) { 45 | if (key === 'isNew') continue; 46 | // skip private stuff 47 | if (key[0] === '_') continue; 48 | const value = this[key]; 49 | // skip functions 50 | if (typeof value === 'function') continue; 51 | obj[key] = value; 52 | } 53 | return obj; 54 | } 55 | 56 | /** 57 | * alias to `toJSON` 58 | */ 59 | [inspect.custom]() { 60 | return this.toJSON(); 61 | } 62 | 63 | /** 64 | * Return how many values there are in the session object. 65 | * Used to see if it's "populated". 66 | */ 67 | get length() { 68 | return Object.keys(this.toJSON()).length; 69 | } 70 | 71 | /** 72 | * populated flag, which is just a boolean alias of .length. 73 | */ 74 | get populated() { 75 | return !!this.length; 76 | } 77 | 78 | /** 79 | * get session maxAge 80 | */ 81 | get maxAge(): number { 82 | return this.#ctx.sessionOptions.maxAge; 83 | } 84 | 85 | /** 86 | * set session maxAge 87 | */ 88 | set maxAge(val: number) { 89 | this.#ctx.sessionOptions.maxAge = val; 90 | // maxAge changed, must save to cookie and store 91 | this._requireSave = true; 92 | } 93 | 94 | /** 95 | * get session external key 96 | * only exist if opts.store present 97 | */ 98 | get externalKey() { 99 | return this.#externalKey; 100 | } 101 | 102 | /** 103 | * save this session no matter whether it is populated 104 | * 105 | * @param {Function} [callback] the optional function to call after saving the session 106 | */ 107 | save(callback?: Callback) { 108 | return this.commit({ save: true }, callback); 109 | } 110 | 111 | /** 112 | * regenerate this session 113 | * 114 | * @param {Function} [callback] the optional function to call after regenerating the session 115 | */ 116 | regenerate(callback?: Callback) { 117 | return this.commit({ regenerate: true }, callback); 118 | } 119 | 120 | /** 121 | * commit this session's headers if autoCommit is set to false 122 | */ 123 | manuallyCommit() { 124 | return this.commit(); 125 | } 126 | 127 | commit(options?: any, callback?: Callback) { 128 | if (typeof options === 'function') { 129 | callback = options; 130 | options = {}; 131 | } 132 | const promise = this.#sessCtx.commit(options); 133 | if (callback) { 134 | promise.then(() => callback(), callback); 135 | } else { 136 | return promise; 137 | } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import crc from 'crc'; 2 | 3 | export default { 4 | /** 5 | * Decode the base64 cookie value to an object 6 | * @private 7 | */ 8 | decode(base64String: string): Record { 9 | const body = Buffer.from(base64String, 'base64').toString('utf8'); 10 | const json = JSON.parse(body); 11 | return json; 12 | }, 13 | 14 | /** 15 | * Encode an object into a base64-encoded JSON string 16 | */ 17 | encode(data: Record) { 18 | return Buffer.from(JSON.stringify(data)).toString('base64'); 19 | }, 20 | 21 | hash(data: Record) { 22 | return crc.crc32(JSON.stringify(data)); 23 | }, 24 | 25 | CookieDateEpoch: 'Thu, 01 Jan 1970 00:00:00 GMT', 26 | }; 27 | -------------------------------------------------------------------------------- /test/context_store.ts: -------------------------------------------------------------------------------- 1 | // this is a stupid nonsense example just to test 2 | 3 | const sessions: Record = {}; 4 | 5 | export default class ContextStore { 6 | ctx: any; 7 | constructor(ctx: any) { 8 | this.ctx = ctx; 9 | } 10 | 11 | async get(key: string) { 12 | return sessions[key]; 13 | } 14 | 15 | async set(key: string, value: unknown) { 16 | sessions[key] = value; 17 | } 18 | 19 | async destroy(key: string) { 20 | sessions[key] = undefined; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /test/contextstore.test.ts: -------------------------------------------------------------------------------- 1 | import { strict as assert } from 'node:assert'; 2 | import { scheduler } from 'node:timers/promises'; 3 | import Koa from 'koa'; 4 | import { request } from '@eggjs/supertest'; 5 | import { mm } from 'mm'; 6 | import session, { type CreateSessionOptions } from '../src/index.js'; 7 | import ContextStore from './context_store.js'; 8 | 9 | const inspect = Symbol.for('nodejs.util.inspect.custom'); 10 | 11 | function App(options: CreateSessionOptions = {}) { 12 | const app = new Koa(); 13 | app.keys = [ 'a', 'b' ]; 14 | options.ContextStore = ContextStore; 15 | options.genid = ctx => { 16 | const sid = Date.now() + '_suffix'; 17 | ctx.state.sid = sid; 18 | return sid; 19 | }; 20 | app.use(session(options, app)); 21 | return app; 22 | } 23 | 24 | describe('Koa Session External Context Store', () => { 25 | let cookie: string; 26 | 27 | describe('when the session contains a ;', () => { 28 | it('should still work', async () => { 29 | const app = App(); 30 | 31 | app.use(async function(ctx) { 32 | if (ctx.method === 'POST') { 33 | ctx.session.string = ';'; 34 | ctx.status = 204; 35 | } else { 36 | ctx.body = ctx.session.string; 37 | } 38 | }); 39 | 40 | const res = await request(app.callback()) 41 | .post('/') 42 | .expect(204); 43 | const cookie = res.get('Set-Cookie')!; 44 | assert(cookie, 'should have set cookie'); 45 | await request(app.callback()) 46 | .get('/') 47 | .set('Cookie', cookie.join(';')) 48 | .expect(';'); 49 | }); 50 | }); 51 | 52 | describe('new session', () => { 53 | describe('when not accessed', () => { 54 | it('should not Set-Cookie', async () => { 55 | const app = App(); 56 | 57 | app.use(async function(ctx) { 58 | ctx.body = 'greetings'; 59 | }); 60 | 61 | const res = await request(app.callback()) 62 | .get('/') 63 | .expect(200); 64 | assert.equal(res.get('Set-Cookie'), undefined, 'should not have set cookie'); 65 | }); 66 | }); 67 | 68 | describe('when accessed and not populated', () => { 69 | it('should not Set-Cookie', async () => { 70 | const app = App(); 71 | 72 | app.use(async function(ctx) { 73 | ctx.session; 74 | ctx.body = 'greetings'; 75 | }); 76 | 77 | const res = await request(app.callback()) 78 | .get('/') 79 | .expect(200); 80 | assert.equal(res.get('Set-Cookie'), undefined, 'should not have set cookie'); 81 | }); 82 | }); 83 | 84 | describe('when populated', () => { 85 | it('should Set-Cookie', async () => { 86 | const app = App(); 87 | 88 | app.use(async function(ctx) { 89 | ctx.session.message = 'hello'; 90 | ctx.body = ''; 91 | }); 92 | 93 | const res = await request(app.callback()) 94 | .get('/') 95 | .expect('Set-Cookie', /koa\.sess/) 96 | .expect(200); 97 | cookie = res.get('Set-Cookie')!.join(';'); 98 | assert.match(cookie, /\d+_suffix/); 99 | }); 100 | 101 | it('should pass sid to middleware', async () => { 102 | const app = App(); 103 | 104 | app.use(async function(ctx) { 105 | ctx.session.message = 'hello'; 106 | assert.match(ctx.state.sid, /\d+_suffix/); 107 | ctx.body = ''; 108 | }); 109 | 110 | const res = await request(app.callback()) 111 | .get('/') 112 | .expect('Set-Cookie', /koa\.sess/) 113 | .expect(200); 114 | 115 | cookie = res.get('Set-Cookie')!.join(';'); 116 | assert.match(cookie, /\d+_suffix/); 117 | }); 118 | 119 | it('should not Set-Cookie', async () => { 120 | const app = App(); 121 | 122 | app.use(async function(ctx) { 123 | ctx.body = ctx.session; 124 | }); 125 | 126 | const res = await request(app.callback()) 127 | .get('/') 128 | .expect(200); 129 | assert.equal(res.get('Set-Cookie'), undefined, 'should not have set cookie'); 130 | }); 131 | }); 132 | }); 133 | 134 | describe('saved session', () => { 135 | describe('when not accessed', () => { 136 | it('should not Set-Cookie', async () => { 137 | const app = App(); 138 | 139 | app.use(async function(ctx) { 140 | ctx.body = 'aklsdjflasdjf'; 141 | }); 142 | 143 | const res = await request(app.callback()) 144 | .get('/') 145 | .set('Cookie', cookie) 146 | .expect(200); 147 | assert.equal(res.get('Set-Cookie'), undefined, 'should not have set cookie'); 148 | }); 149 | }); 150 | 151 | describe('when accessed but not changed', () => { 152 | it('should be the same session', async () => { 153 | const app = App(); 154 | 155 | app.use(async function(ctx) { 156 | assert.equal(ctx.session.message, 'hello'); 157 | ctx.body = 'aklsdjflasdjf'; 158 | }); 159 | 160 | await request(app.callback()) 161 | .get('/') 162 | .set('Cookie', cookie) 163 | .expect(200); 164 | }); 165 | 166 | it('should not Set-Cookie', async () => { 167 | const app = App(); 168 | 169 | app.use(async function(ctx) { 170 | assert.equal(ctx.session.message, 'hello'); 171 | ctx.body = 'aklsdjflasdjf'; 172 | }); 173 | 174 | const res = await request(app.callback()) 175 | .get('/') 176 | .set('Cookie', cookie) 177 | .expect(200); 178 | assert.equal(res.get('Set-Cookie'), undefined, 'should not have set cookie'); 179 | }); 180 | }); 181 | 182 | describe('when accessed and changed', () => { 183 | it('should Set-Cookie', async () => { 184 | const app = App(); 185 | 186 | app.use(async function(ctx) { 187 | ctx.session.money = '$$$'; 188 | ctx.body = 'aklsdjflasdjf'; 189 | }); 190 | 191 | await request(app.callback()) 192 | .get('/') 193 | .set('Cookie', cookie) 194 | .expect('Set-Cookie', /koa\.sess/) 195 | .expect(200); 196 | }); 197 | }); 198 | }); 199 | 200 | describe('when session is', () => { 201 | describe('null', () => { 202 | it('should expire the session', async () => { 203 | const app = App(); 204 | 205 | app.use(async function(ctx) { 206 | ctx.session = null; 207 | ctx.body = 'asdf'; 208 | }); 209 | 210 | await request(app.callback()) 211 | .get('/') 212 | .expect('Set-Cookie', /koa\.sess/); 213 | }); 214 | }); 215 | 216 | describe('an empty object', () => { 217 | it('should not Set-Cookie', async () => { 218 | const app = App(); 219 | 220 | app.use(async function(ctx) { 221 | ctx.session = {}; 222 | ctx.body = 'asdf'; 223 | }); 224 | 225 | const res = await request(app.callback()) 226 | .get('/') 227 | .expect(200); 228 | assert.equal(res.get('Set-Cookie'), undefined, 'should not have set cookie'); 229 | }); 230 | }); 231 | 232 | describe('an object', () => { 233 | it('should create a session', async () => { 234 | const app = App(); 235 | 236 | app.use(async function(ctx) { 237 | ctx.session = { message: 'hello' }; 238 | ctx.body = 'asdf'; 239 | }); 240 | 241 | await request(app.callback()) 242 | .get('/') 243 | .expect('Set-Cookie', /koa\.sess/) 244 | .expect(200); 245 | }); 246 | }); 247 | 248 | describe('anything else', () => { 249 | it('should throw', async () => { 250 | const app = App(); 251 | 252 | app.use(async function(ctx) { 253 | ctx.session = 'asdf'; 254 | }); 255 | 256 | await request(app.callback()) 257 | .get('/') 258 | .expect(500); 259 | }); 260 | }); 261 | }); 262 | 263 | describe('session', () => { 264 | describe('.inspect()', () => { 265 | it('should return session content', async () => { 266 | const app = App(); 267 | 268 | app.use(async function(ctx) { 269 | ctx.session.foo = 'bar'; 270 | ctx.body = ctx.session[inspect](); 271 | }); 272 | 273 | await request(app.callback()) 274 | .get('/') 275 | .expect('Set-Cookie', /koa\.sess=.+;/) 276 | .expect({ foo: 'bar' }) 277 | .expect(200); 278 | }); 279 | }); 280 | 281 | describe('.length', () => { 282 | it('should return session length', async () => { 283 | const app = App(); 284 | 285 | app.use(async function(ctx) { 286 | ctx.session.foo = 'bar'; 287 | ctx.body = String(ctx.session.length); 288 | }); 289 | 290 | await request(app.callback()) 291 | .get('/') 292 | .expect('Set-Cookie', /koa\.sess=.+;/) 293 | .expect('1') 294 | .expect(200); 295 | }); 296 | }); 297 | 298 | describe('.populated', () => { 299 | it('should return session populated', async () => { 300 | const app = App(); 301 | 302 | app.use(async function(ctx) { 303 | ctx.session.foo = 'bar'; 304 | ctx.body = String(ctx.session.populated); 305 | }); 306 | 307 | await request(app.listen()) 308 | .get('/') 309 | .expect('Set-Cookie', /koa\.sess=.+;/) 310 | .expect('true') 311 | .expect(200); 312 | }); 313 | }); 314 | 315 | describe('.save()', () => { 316 | it('should save session', async () => { 317 | const app = App(); 318 | 319 | app.use(async function(ctx) { 320 | ctx.session.save(); 321 | ctx.body = 'hello'; 322 | }); 323 | 324 | await request(app.callback()) 325 | .get('/') 326 | .expect('Set-Cookie', /koa\.sess=.+;/) 327 | .expect('hello') 328 | .expect(200); 329 | }); 330 | }); 331 | }); 332 | 333 | describe('when an error is thrown downstream and caught upstream', () => { 334 | it('should still save the session', async () => { 335 | const app = new Koa(); 336 | 337 | app.keys = [ 'a', 'b' ]; 338 | 339 | app.use(async function(ctx, next) { 340 | try { 341 | await next(); 342 | } catch (err: any) { 343 | ctx.status = err.status; 344 | ctx.body = err.message; 345 | } 346 | }); 347 | 348 | app.use(session({ ContextStore }, app)); 349 | 350 | app.use(async function(ctx, next) { 351 | ctx.session.name = 'funny'; 352 | await next(); 353 | }); 354 | 355 | app.use(async function(ctx) { 356 | ctx.throw(401); 357 | }); 358 | 359 | await request(app.callback()) 360 | .get('/') 361 | .expect('Set-Cookie', /koa\.sess/) 362 | .expect(401); 363 | }); 364 | }); 365 | 366 | describe('when autoCommit is present', () => { 367 | describe('and set to false', () => { 368 | it('should not set headers if manuallyCommit() isn\'t called', async () => { 369 | const app = App({ autoCommit: false }); 370 | 371 | app.use(async function(ctx) { 372 | if (ctx.method === 'POST') { 373 | ctx.session.message = 'hi'; 374 | ctx.body = 200; 375 | return; 376 | } 377 | ctx.body = ctx.session.message; 378 | }); 379 | 380 | const res = await request(app.callback()) 381 | .post('/') 382 | .expect(200); 383 | assert.equal(res.get('Set-Cookie'), undefined, 'should not have set cookie'); 384 | }); 385 | 386 | it('should set headers if manuallyCommit() is called', async () => { 387 | const app = App({ autoCommit: false }); 388 | app.use(async function(ctx, next) { 389 | if (ctx.method === 'POST') { 390 | ctx.session.message = 'dummy'; 391 | } 392 | await next(); 393 | }); 394 | app.use(async function(ctx) { 395 | ctx.body = 200; 396 | await ctx.session.manuallyCommit(); 397 | }); 398 | 399 | await request(app.callback()) 400 | .post('/') 401 | .expect('Set-Cookie', /koa\.sess/) 402 | .expect(200); 403 | }); 404 | }); 405 | }); 406 | 407 | describe('when maxAge present', () => { 408 | describe('and set to be a session cookie', () => { 409 | it('should not expire the session', async () => { 410 | const app = App({ maxAge: 'session' }); 411 | 412 | app.use(async function(ctx) { 413 | if (ctx.method === 'POST') { 414 | ctx.session.message = 'hi'; 415 | ctx.body = 200; 416 | return; 417 | } 418 | ctx.body = ctx.session.message; 419 | }); 420 | 421 | const res = await request(app.callback()) 422 | .post('/') 423 | .expect('Set-Cookie', /koa\.sess/) 424 | .expect(200); 425 | 426 | const cookie = res.get('Set-Cookie')!.join(';'); 427 | assert.doesNotMatch(cookie, /expires=/); 428 | await request(app.callback()) 429 | .get('/') 430 | .set('cookie', cookie) 431 | .expect('hi'); 432 | }); 433 | 434 | it('should not expire the session after multiple session changes', async () => { 435 | const app = App({ maxAge: 'session' }); 436 | 437 | app.use(async function(ctx) { 438 | ctx.session.count = (ctx.session.count || 0) + 1; 439 | ctx.body = `hi ${ctx.session.count}`; 440 | }); 441 | 442 | let res = await request(app.callback()) 443 | .get('/') 444 | .expect('Set-Cookie', /koa\.sess/) 445 | .expect('hi 1') 446 | .expect(200); 447 | let cookie = res.get('Set-Cookie')!.join(';'); 448 | assert.doesNotMatch(cookie, /expires=/); 449 | 450 | res = await request(app.callback()) 451 | .get('/') 452 | .set('cookie', cookie) 453 | .expect('Set-Cookie', /koa\.sess/) 454 | .expect('hi 2') 455 | .expect(200); 456 | cookie = res.get('Set-Cookie')!.join(';'); 457 | assert.doesNotMatch(cookie, /expires=/); 458 | 459 | res = await request(app.callback()) 460 | .get('/') 461 | .set('cookie', cookie) 462 | .expect('Set-Cookie', /koa\.sess/) 463 | .expect('hi 3') 464 | .expect(200); 465 | cookie = res.get('Set-Cookie')!.join(';'); 466 | assert.doesNotMatch(cookie, /expires=/); 467 | }); 468 | 469 | it('should throw error when the maxAge improper string given', () => { 470 | assert.throws(() => { 471 | App({ maxAge: 'not the right string' } as any); 472 | }, /Invalid input/); 473 | }); 474 | }); 475 | 476 | describe('and not expire', () => { 477 | it('should not expire the session', async () => { 478 | const app = App({ maxAge: 100 }); 479 | 480 | app.use(async function(ctx) { 481 | if (ctx.method === 'POST') { 482 | ctx.session.message = 'hi'; 483 | ctx.body = 200; 484 | return; 485 | } 486 | ctx.body = ctx.session.message; 487 | }); 488 | 489 | const res = await request(app.callback()) 490 | .post('/') 491 | .expect('Set-Cookie', /koa\.sess/) 492 | .expect(200); 493 | const cookie = res.get('Set-Cookie')!.join(';'); 494 | await request(app.callback()) 495 | .get('/') 496 | .set('cookie', cookie) 497 | .expect('hi'); 498 | }); 499 | }); 500 | 501 | describe('and expired', () => { 502 | it('should expire the sess', async () => { 503 | const app = App({ maxAge: 100 }); 504 | 505 | app.use(async function(ctx) { 506 | if (ctx.method === 'POST') { 507 | ctx.session.message = 'hi'; 508 | ctx.status = 200; 509 | return; 510 | } 511 | 512 | ctx.body = ctx.session.message || ''; 513 | }); 514 | 515 | const res = await request(app.callback()) 516 | .post('/') 517 | .expect('Set-Cookie', /koa\.sess/) 518 | .expect(200); 519 | const cookie = res.get('Set-Cookie')!.join(';'); 520 | await scheduler.wait(200); 521 | await request(app.callback()) 522 | .get('/') 523 | .set('cookie', cookie) 524 | .expect(''); 525 | }); 526 | }); 527 | }); 528 | 529 | describe('ctx.session.maxAge', () => { 530 | it('should return opt.maxAge', async () => { 531 | const app = App({ maxAge: 100 }); 532 | 533 | app.use(async function(ctx) { 534 | ctx.body = ctx.session.maxAge; 535 | }); 536 | 537 | await request(app.callback()) 538 | .get('/') 539 | .expect('100'); 540 | }); 541 | }); 542 | 543 | describe('ctx.session.maxAge=', () => { 544 | it('should set sessionOptions.maxAge', async () => { 545 | const app = App(); 546 | 547 | app.use(async function(ctx) { 548 | ctx.session.foo = 'bar'; 549 | ctx.session.maxAge = 100; 550 | ctx.body = ctx.session.foo; 551 | }); 552 | 553 | await request(app.callback()) 554 | .get('/') 555 | .expect('Set-Cookie', /expires=/) 556 | .expect(200); 557 | }); 558 | 559 | it('should save even session not change', async () => { 560 | const app = App(); 561 | 562 | app.use(async function(ctx) { 563 | ctx.session.maxAge = 100; 564 | ctx.body = ctx.session; 565 | }); 566 | 567 | await request(app.callback()) 568 | .get('/') 569 | .expect('Set-Cookie', /expires=/) 570 | .expect(200); 571 | }); 572 | 573 | it('should save when create session only with maxAge', async () => { 574 | const app = App(); 575 | 576 | app.use(async function(ctx) { 577 | ctx.session = { maxAge: 100 }; 578 | ctx.body = ctx.session; 579 | }); 580 | 581 | await request(app.callback()) 582 | .get('/') 583 | .expect('Set-Cookie', /expires=/) 584 | .expect(200); 585 | }); 586 | }); 587 | 588 | describe('when store return empty', () => { 589 | it('should create new Session', async () => { 590 | const app = App({ signed: false }); 591 | 592 | app.use(async function(ctx) { 593 | ctx.body = String(ctx.session.isNew); 594 | }); 595 | 596 | await request(app.callback()) 597 | .get('/') 598 | .set('cookie', 'koa.sess=invalid-key') 599 | .expect('true') 600 | .expect(200); 601 | }); 602 | }); 603 | 604 | describe('when valid and beforeSave set', () => { 605 | it('should ignore session when uid changed', async () => { 606 | const app = new Koa(); 607 | 608 | app.keys = [ 'a', 'b' ]; 609 | app.use(session({ 610 | valid(ctx, sess) { 611 | return ctx.cookies.get('uid') === sess.uid; 612 | }, 613 | beforeSave(ctx, sess) { 614 | sess.uid = ctx.cookies.get('uid'); 615 | }, 616 | ContextStore, 617 | }, app)); 618 | 619 | app.use(async function(ctx) { 620 | if (!ctx.session.foo) { 621 | ctx.session.foo = Date.now() + '|uid:' + ctx.cookies.get('uid'); 622 | } 623 | 624 | ctx.body = { 625 | foo: ctx.session.foo, 626 | uid: ctx.cookies.get('uid'), 627 | }; 628 | }); 629 | 630 | let res = await request(app.callback()) 631 | .get('/') 632 | .set('Cookie', 'uid=123') 633 | .expect(200); 634 | 635 | const data = res.body; 636 | const cookies = res.get('Set-Cookie')!.join(';'); 637 | assert.match(cookies, /koa\.sess=/); 638 | 639 | res = await request(app.callback()) 640 | .get('/') 641 | .set('Cookie', cookies + ';uid=123') 642 | .expect(200) 643 | .expect(data); 644 | 645 | // should ignore uid:123 session and create a new session for uid:456 646 | res = await request(app.callback()) 647 | .get('/') 648 | .set('Cookie', cookies + ';uid=456') 649 | .expect(200); 650 | assert.equal(res.body.uid, '456'); 651 | assert.notEqual(res.body.foo, data.foo); 652 | }); 653 | }); 654 | 655 | describe('ctx.session', () => { 656 | after(mm.restore); 657 | 658 | it('can be mocked', async () => { 659 | const app = App(); 660 | 661 | app.use(async function(ctx) { 662 | ctx.body = ctx.session; 663 | }); 664 | 665 | mm(app.context, 'session', { 666 | foo: 'bar', 667 | }); 668 | 669 | await request(app.callback()) 670 | .get('/') 671 | .expect({ 672 | foo: 'bar', 673 | }) 674 | .expect(200); 675 | }); 676 | }); 677 | }); 678 | -------------------------------------------------------------------------------- /test/cookie.test.ts: -------------------------------------------------------------------------------- 1 | import { strict as assert } from 'node:assert'; 2 | import Koa from 'koa'; 3 | import { request } from '@eggjs/supertest'; 4 | import session, { type CreateSessionOptions } from '../src/index.js'; 5 | 6 | function App(options: CreateSessionOptions = {}) { 7 | const app = new Koa(); 8 | app.keys = [ 'a', 'b' ]; 9 | app.use(session(options, app)); 10 | return app; 11 | } 12 | 13 | const inspect = Symbol.for('nodejs.util.inspect.custom'); 14 | 15 | describe('Koa Session Cookie', () => { 16 | let cookie: string; 17 | 18 | describe('when options.signed = true', () => { 19 | describe('when app.keys are set', () => { 20 | it('should work', async () => { 21 | const app = new Koa(); 22 | 23 | app.keys = [ 'a', 'b' ]; 24 | app.use(session({}, app)); 25 | 26 | app.use(async (ctx: Koa.Context) => { 27 | ctx.session!.message = 'hi'; 28 | ctx.body = ctx.session; 29 | }); 30 | 31 | await request(app.callback()) 32 | .get('/') 33 | .expect(200); 34 | }); 35 | }); 36 | 37 | describe('when app.keys are not set', () => { 38 | it('should throw and clean this cookie', async () => { 39 | const app = new Koa(); 40 | 41 | app.use(session(app)); 42 | 43 | app.use(async (ctx: Koa.Context) => { 44 | ctx.session!.message = 'hi'; 45 | ctx.body = ctx.session; 46 | }); 47 | 48 | await request(app.callback()) 49 | .get('/') 50 | .expect(500); 51 | }); 52 | }); 53 | 54 | describe('when app not set', () => { 55 | it('should throw', () => { 56 | const app = new Koa(); 57 | assert.throws(() => { 58 | app.use((session as any)()); 59 | }, /app instance required: `session\(opts, app\)`/); 60 | assert.throws(() => { 61 | app.use(session({})); 62 | }, /app instance required: `session\(opts, app\)`/); 63 | }); 64 | }); 65 | }); 66 | 67 | describe('when options.signed = false', () => { 68 | describe('when app.keys are not set', () => { 69 | it('should work', async () => { 70 | const app = new Koa(); 71 | 72 | app.use(session({ signed: false }, app)); 73 | 74 | app.use(async (ctx: Koa.Context) => { 75 | ctx.session!.message = 'hi'; 76 | ctx.body = ctx.session; 77 | }); 78 | 79 | await request(app.callback()) 80 | .get('/') 81 | .expect(200); 82 | }); 83 | }); 84 | }); 85 | 86 | describe('when the session contains a ;', () => { 87 | it('should still work', async () => { 88 | const app = App(); 89 | 90 | app.use(async (ctx: Koa.Context) => { 91 | if (ctx.method === 'POST') { 92 | ctx.session!.string = ';'; 93 | ctx.status = 204; 94 | } else { 95 | ctx.body = ctx.session!.string; 96 | } 97 | }); 98 | 99 | const server = app.callback(); 100 | 101 | const res = await request(server) 102 | .post('/') 103 | .expect(204); 104 | 105 | const cookie = res.get('Set-Cookie')!; 106 | // samesite is not set 107 | assert(!cookie.join(';').includes('samesite')); 108 | await request(server) 109 | .get('/') 110 | .set('Cookie', cookie.join(';')) 111 | .expect(';'); 112 | }); 113 | }); 114 | 115 | describe('new session', () => { 116 | describe('when not accessed', () => { 117 | it('should not Set-Cookie', async () => { 118 | const app = App(); 119 | 120 | app.use(async (ctx: Koa.Context) => { 121 | ctx.body = 'greetings'; 122 | }); 123 | 124 | const res = await request(app.callback()) 125 | .get('/') 126 | .expect(200); 127 | assert.equal(res.header['set-cookie'], undefined); 128 | }); 129 | }); 130 | 131 | describe('when accessed and not populated', () => { 132 | it('should not Set-Cookie', async () => { 133 | const app = App(); 134 | 135 | app.use(async (ctx: Koa.Context) => { 136 | ctx.session; 137 | ctx.body = 'greetings'; 138 | }); 139 | 140 | const res = await request(app.callback()) 141 | .get('/') 142 | .expect(200); 143 | assert.equal(res.header['set-cookie'], undefined); 144 | }); 145 | }); 146 | 147 | describe('when populated', () => { 148 | it('should Set-Cookie', async () => { 149 | const app = App(); 150 | 151 | app.use(async (ctx: Koa.Context) => { 152 | ctx.session!.message = 'hello'; 153 | ctx.body = ''; 154 | }); 155 | 156 | const res = await request(app.callback()) 157 | .get('/') 158 | .expect('Set-Cookie', /koa\.sess/) 159 | .expect(200); 160 | cookie = res.get('Set-Cookie')!.join(';'); 161 | }); 162 | 163 | it('should not Set-Cookie', async () => { 164 | const app = App(); 165 | 166 | app.use(async (ctx: Koa.Context) => { 167 | ctx.body = ctx.session; 168 | }); 169 | 170 | const res = await request(app.callback()) 171 | .get('/') 172 | .expect(200); 173 | assert.equal(res.header['set-cookie'], undefined); 174 | }); 175 | }); 176 | }); 177 | 178 | describe('saved session', () => { 179 | describe('when not accessed', () => { 180 | it('should not Set-Cookie', async () => { 181 | const app = App(); 182 | 183 | app.use(async (ctx: Koa.Context) => { 184 | ctx.body = 'aklsdjflasdjf'; 185 | }); 186 | 187 | const res = await request(app.callback()) 188 | .get('/') 189 | .set('Cookie', cookie) 190 | .expect(200); 191 | assert.equal(res.header['set-cookie'], undefined); 192 | }); 193 | }); 194 | 195 | describe('when accessed but not changed', () => { 196 | it('should be the same session', async () => { 197 | const app = App(); 198 | 199 | app.use(async (ctx: Koa.Context) => { 200 | assert.equal(ctx.session!.message, 'hello'); 201 | ctx.body = 'aklsdjflasdjf'; 202 | }); 203 | 204 | await request(app.callback()) 205 | .get('/') 206 | .set('Cookie', cookie) 207 | .expect(200); 208 | }); 209 | 210 | it('should not Set-Cookie', async () => { 211 | const app = App(); 212 | 213 | app.use(async (ctx: Koa.Context) => { 214 | assert.equal(ctx.session!.message, 'hello'); 215 | ctx.body = 'aklsdjflasdjf'; 216 | }); 217 | 218 | const res = await request(app.callback()) 219 | .get('/') 220 | .set('Cookie', cookie) 221 | .expect(200); 222 | assert.equal(res.header['set-cookie'], undefined); 223 | }); 224 | }); 225 | 226 | describe('when accessed and changed', () => { 227 | it('should Set-Cookie', async () => { 228 | const app = App(); 229 | 230 | app.use(async (ctx: Koa.Context) => { 231 | ctx.session!.money = '$$$'; 232 | ctx.body = 'aklsdjflasdjf'; 233 | }); 234 | 235 | const res = await request(app.callback()) 236 | .get('/') 237 | .set('Cookie', cookie) 238 | .expect('Set-Cookie', /koa\.sess/) 239 | .expect(200); 240 | const newCookie = res.get('Set-Cookie')!; 241 | // samesite is not set 242 | assert(!newCookie.join(';').includes('samesite')); 243 | }); 244 | }); 245 | }); 246 | 247 | describe('after session set to null with signed cookie', () => { 248 | it('should return expired cookies', async () => { 249 | const app = App({ 250 | signed: true, 251 | }); 252 | 253 | app.use(async (ctx: Koa.Context) => { 254 | ctx.session!.hello = {}; 255 | ctx.session = null; 256 | ctx.body = String(ctx.session === null); 257 | }); 258 | 259 | await request(app.callback()) 260 | .get('/') 261 | .expect('Set-Cookie', 262 | /koa\.sess=; path=\/; expires=Thu, 01 Jan 1970 00:00:00 GMT/) 263 | .expect('Set-Cookie', 264 | /koa\.sess.sig=(.*); path=\/; expires=Thu, 01 Jan 1970 00:00:00 GMT/) 265 | .expect('true') 266 | .expect(200); 267 | }); 268 | }); 269 | 270 | describe('after session set to null without signed cookie', () => { 271 | it('should return expired cookies', async () => { 272 | const app = App({ 273 | signed: false, 274 | }); 275 | 276 | app.use(async (ctx: Koa.Context) => { 277 | ctx.session!.hello = {}; 278 | ctx.session = null; 279 | ctx.body = String(ctx.session === null); 280 | }); 281 | 282 | await request(app.callback()) 283 | .get('/') 284 | .expect('Set-Cookie', /koa\.sess=; path=\/; expires=Thu, 01 Jan 1970 00:00:00 GMT/) 285 | .expect('true') 286 | .expect(200); 287 | }); 288 | }); 289 | 290 | describe('when get session after set to null', () => { 291 | it('should return null', async () => { 292 | const app = App(); 293 | 294 | app.use(async (ctx: Koa.Context) => { 295 | ctx.session!.hello = {}; 296 | ctx.session = null; 297 | ctx.body = String(ctx.session === null); 298 | }); 299 | 300 | await request(app.callback()) 301 | .get('/') 302 | .expect('Set-Cookie', /koa\.sess=;/) 303 | .expect('true') 304 | .expect(200); 305 | }); 306 | }); 307 | 308 | describe('when decode session', () => { 309 | describe('SyntaxError', () => { 310 | it('should create new session', async () => { 311 | const app = App({ signed: false }); 312 | 313 | app.use(async (ctx: Koa.Context) => { 314 | ctx.body = String(ctx.session.isNew); 315 | }); 316 | 317 | await request(app.callback()) 318 | .get('/') 319 | .set('cookie', 'koa.sess=invalid-session;') 320 | .expect('true') 321 | .expect(200); 322 | }); 323 | }); 324 | 325 | describe('Other Error', () => { 326 | it('should throw', async () => { 327 | const app = App({ 328 | signed: false, 329 | decode() { 330 | throw new Error('decode error'); 331 | }, 332 | }); 333 | 334 | app.use(async (ctx: Koa.Context) => { 335 | ctx.body = String(ctx.session!.isNew); 336 | }); 337 | 338 | await request(app.callback()) 339 | .get('/') 340 | .set('cookie', 'koa.sess=invalid-session;') 341 | .expect('Set-Cookie', /koa\.sess=;/) 342 | .expect(500); 343 | }); 344 | }); 345 | }); 346 | 347 | describe('when encode session error', () => { 348 | it('should throw', async () => { 349 | const app = App({ 350 | encode() { 351 | throw new Error('encode error'); 352 | }, 353 | }); 354 | 355 | app.use(async (ctx: Koa.Context) => { 356 | ctx.session!.foo = 'bar'; 357 | ctx.body = 'hello'; 358 | }); 359 | 360 | app.once('error', (err, ctx) => { 361 | assert.equal(err.message, 'encode error'); 362 | assert(ctx); 363 | }); 364 | 365 | await request(app.callback()) 366 | .get('/') 367 | .expect(500); 368 | }); 369 | }); 370 | 371 | describe('session', () => { 372 | describe('.inspect()', () => { 373 | it('should return session content', async () => { 374 | const app = App(); 375 | 376 | app.use(async (ctx: Koa.Context) => { 377 | ctx.session!.foo = 'bar'; 378 | ctx.body = ctx.session![inspect](); 379 | }); 380 | 381 | await request(app.callback()) 382 | .get('/') 383 | .expect('Set-Cookie', /koa\.sess=.+;/) 384 | .expect({ foo: 'bar' }) 385 | .expect(200); 386 | }); 387 | }); 388 | 389 | describe('.length', () => { 390 | it('should return session length', async () => { 391 | const app = App(); 392 | 393 | app.use(async (ctx: Koa.Context) => { 394 | ctx.session!.foo = 'bar'; 395 | ctx.body = String(ctx.session!.length); 396 | }); 397 | 398 | await request(app.callback()) 399 | .get('/') 400 | .expect('Set-Cookie', /koa\.sess=.+;/) 401 | .expect('1') 402 | .expect(200); 403 | }); 404 | }); 405 | 406 | describe('.populated', () => { 407 | it('should return session populated', async () => { 408 | const app = App(); 409 | 410 | app.use(async (ctx: Koa.Context) => { 411 | ctx.session!.foo = 'bar'; 412 | ctx.body = String(ctx.session!.populated); 413 | }); 414 | 415 | await request(app.callback()) 416 | .get('/') 417 | .expect('Set-Cookie', /koa\.sess=.+;/) 418 | .expect('true') 419 | .expect(200); 420 | }); 421 | }); 422 | 423 | describe('.save()', () => { 424 | it('should save session', async () => { 425 | const app = App(); 426 | 427 | app.use(async (ctx: Koa.Context) => { 428 | ctx.session!.save(); 429 | ctx.body = 'hello'; 430 | }); 431 | 432 | await request(app.callback()) 433 | .get('/') 434 | .expect('Set-Cookie', /koa\.sess=.+;/) 435 | .expect('hello') 436 | .expect(200); 437 | }); 438 | }); 439 | }); 440 | 441 | describe('when session is', () => { 442 | describe('null', () => { 443 | it('should expire the session', async () => { 444 | const app = App(); 445 | 446 | app.use(async (ctx: Koa.Context) => { 447 | ctx.session = null; 448 | ctx.body = 'asdf'; 449 | }); 450 | 451 | await request(app.callback()) 452 | .get('/') 453 | .expect('Set-Cookie', /koa\.sess/) 454 | .expect(200); 455 | }); 456 | }); 457 | 458 | describe('an empty object', () => { 459 | it('should not Set-Cookie', async () => { 460 | const app = App(); 461 | 462 | app.use(async (ctx: Koa.Context) => { 463 | ctx.session = {}; 464 | ctx.body = 'asdf'; 465 | }); 466 | 467 | const res = await request(app.callback()) 468 | .get('/') 469 | .expect(200); 470 | assert.equal(res.header['set-cookie'], undefined); 471 | }); 472 | }); 473 | 474 | describe('an object', () => { 475 | it('should create a session', async () => { 476 | const app = App(); 477 | 478 | app.use(async (ctx: Koa.Context) => { 479 | ctx.session = { message: 'hello' }; 480 | ctx.body = 'asdf'; 481 | }); 482 | 483 | await request(app.callback()) 484 | .get('/') 485 | .expect('Set-Cookie', /koa\.sess/) 486 | .expect(200); 487 | }); 488 | }); 489 | 490 | describe('anything else', () => { 491 | it('should throw', async () => { 492 | const app = App(); 493 | 494 | app.use(async (ctx: Koa.Context) => { 495 | ctx.session = 'asdf'; 496 | }); 497 | 498 | await request(app.callback()) 499 | .get('/') 500 | .expect(/Internal Server Error/) 501 | .expect(500); 502 | }); 503 | }); 504 | }); 505 | 506 | describe('when an error is thrown downstream and caught upstream', () => { 507 | it('should still save the session', async () => { 508 | const app = new Koa(); 509 | 510 | app.keys = [ 'a', 'b' ]; 511 | 512 | app.use(async (ctx: Koa.Context, next: Koa.Next) => { 513 | try { 514 | await next(); 515 | } catch (err: any) { 516 | ctx.status = err.status; 517 | ctx.body = err.message; 518 | } 519 | }); 520 | 521 | app.use(session(app)); 522 | 523 | app.use(async (ctx: Koa.Context, next: Koa.Next) => { 524 | ctx.session.name = 'funny'; 525 | await next(); 526 | }); 527 | 528 | app.use(async (ctx: Koa.Context) => { 529 | ctx.throw(401); 530 | }); 531 | 532 | await request(app.callback()) 533 | .get('/') 534 | .expect('Set-Cookie', /koa\.sess/) 535 | .expect(401); 536 | }); 537 | }); 538 | 539 | describe('when maxAge present', () => { 540 | describe('and not expire', () => { 541 | it('should not expire the session', async () => { 542 | const app = App({ maxAge: 100 }); 543 | 544 | app.use(async (ctx: Koa.Context) => { 545 | if (ctx.method === 'POST') { 546 | ctx.session!.message = 'hi'; 547 | ctx.body = 200; 548 | return; 549 | } 550 | ctx.body = ctx.session!.message; 551 | }); 552 | 553 | const server = app.callback(); 554 | 555 | const res = await request(server) 556 | .post('/') 557 | .expect('Set-Cookie', /koa\.sess/); 558 | 559 | const cookie = res.get('Set-Cookie')!.join(';'); 560 | 561 | await request(server) 562 | .get('/') 563 | .set('cookie', cookie) 564 | .expect('hi'); 565 | }); 566 | }); 567 | 568 | describe('and expired', () => { 569 | it('should expire the sess', async () => { 570 | const app = App({ maxAge: 100 }); 571 | 572 | app.use(async (ctx: Koa.Context) => { 573 | if (ctx.method === 'POST') { 574 | ctx.session!.message = 'hi'; 575 | ctx.status = 200; 576 | return; 577 | } 578 | 579 | ctx.body = ctx.session.message || ''; 580 | }); 581 | 582 | const server = app.callback(); 583 | 584 | const res = await request(server) 585 | .post('/') 586 | .expect('Set-Cookie', /koa\.sess/); 587 | 588 | const cookie = res.get('Set-Cookie')!.join(';'); 589 | 590 | await new Promise(resolve => setTimeout(resolve, 200)); 591 | 592 | await request(server) 593 | .get('/') 594 | .set('cookie', cookie) 595 | .expect(''); 596 | }); 597 | }); 598 | }); 599 | 600 | describe('ctx.session.maxAge', () => { 601 | it('should return opt.maxAge', async () => { 602 | const app = App({ maxAge: 100 }); 603 | 604 | app.use(async (ctx: Koa.Context) => { 605 | ctx.body = ctx.session!.maxAge; 606 | }); 607 | 608 | await request(app.callback()) 609 | .get('/') 610 | .expect('100'); 611 | }); 612 | }); 613 | 614 | describe('ctx.session.maxAge=', () => { 615 | it('should set sessionOptions.maxAge', async () => { 616 | const app = App(); 617 | 618 | app.use(async (ctx: Koa.Context) => { 619 | ctx.session!.foo = 'bar'; 620 | ctx.session!.maxAge = 100; 621 | ctx.body = ctx.session!.foo; 622 | }); 623 | 624 | await request(app.callback()) 625 | .get('/') 626 | .expect('Set-Cookie', /expires=/) 627 | .expect(200); 628 | }); 629 | 630 | it('should save even session not change', async () => { 631 | const app = App(); 632 | 633 | app.use(async (ctx: Koa.Context) => { 634 | ctx.session!.maxAge = 100; 635 | ctx.body = ctx.session; 636 | }); 637 | 638 | await request(app.callback()) 639 | .get('/') 640 | .expect('Set-Cookie', /expires=/) 641 | .expect(200); 642 | }); 643 | 644 | it('should save when create session only with maxAge', async () => { 645 | const app = App(); 646 | 647 | app.use(async (ctx: Koa.Context) => { 648 | ctx.session = { maxAge: 100 }; 649 | ctx.body = ctx.session; 650 | }); 651 | 652 | await request(app.callback()) 653 | .get('/') 654 | .expect('Set-Cookie', /expires=/) 655 | .expect(200); 656 | }); 657 | }); 658 | 659 | describe('ctx.session.regenerate', () => { 660 | it('should change the session key, but not content', async () => { 661 | const app = App(); 662 | const message = 'hi'; 663 | app.use(async (ctx: Koa.Context, next: Koa.Next) => { 664 | ctx.session = { message: 'hi' }; 665 | await next(); 666 | }); 667 | 668 | app.use(async (ctx: Koa.Context, next: Koa.Next) => { 669 | const sessionKey = ctx.cookies.get('koa.sess'); 670 | if (sessionKey) { 671 | await ctx.session!.regenerate(); 672 | } 673 | await next(); 674 | }); 675 | 676 | app.use(async (ctx: Koa.Context) => { 677 | assert.equal(ctx.session!.message, message); 678 | ctx.body = ''; 679 | }); 680 | let res = await request(app.callback()) 681 | .get('/') 682 | .expect(200); 683 | 684 | const koaSession = res.get('Set-Cookie')!.join(';'); 685 | assert.match(koaSession, /koa\.sess=/); 686 | res = await request(app.callback()) 687 | .get('/') 688 | .set('Cookie', koaSession) 689 | .expect(200); 690 | 691 | const cookies = res.get('Set-Cookie')!.join(';'); 692 | assert.match(cookies, /koa\.sess=/); 693 | assert.notEqual(cookies, koaSession); 694 | }); 695 | }); 696 | 697 | describe('when get session before enter session middleware', () => { 698 | it('should work', async () => { 699 | const app = new Koa(); 700 | 701 | app.keys = [ 'a', 'b' ]; 702 | app.use(async (ctx: Koa.Context, next: Koa.Next) => { 703 | ctx.session!.foo = 'hi'; 704 | await next(); 705 | }); 706 | app.use(session({}, app)); 707 | app.use(async (ctx: Koa.Context) => { 708 | ctx.body = ctx.session; 709 | }); 710 | 711 | const res = await request(app.callback()) 712 | .get('/') 713 | .expect(200); 714 | 715 | const cookies = res.get('Set-Cookie')!.join(';'); 716 | assert(cookies.includes('koa.sess=')); 717 | 718 | await request(app.callback()) 719 | .get('/') 720 | .set('Cookie', cookies) 721 | .expect(200); 722 | }); 723 | }); 724 | 725 | describe('options.sameSite', () => { 726 | it('should return opt.sameSite=none', async () => { 727 | const app = App({ sameSite: 'none' }); 728 | 729 | app.use(async (ctx: Koa.Context) => { 730 | ctx.session = { foo: 'bar' }; 731 | ctx.body = ctx.session.foo; 732 | }); 733 | 734 | const res = await request(app.callback()) 735 | .get('/') 736 | .expect('bar') 737 | .expect(200); 738 | const cookie = res.get('Set-Cookie')!.join('|'); 739 | assert(cookie.includes('path=/; samesite=none; httponly')); 740 | }); 741 | 742 | it('should return opt.sameSite=lax', async () => { 743 | const app = App({ sameSite: 'lax' }); 744 | 745 | app.use(async (ctx: Koa.Context) => { 746 | ctx.session = { foo: 'bar' }; 747 | ctx.body = ctx.session.foo; 748 | }); 749 | 750 | const res = await request(app.callback()) 751 | .get('/') 752 | .expect('bar') 753 | .expect(200); 754 | const cookie = res.get('Set-Cookie')!.join('|'); 755 | assert(cookie.includes('path=/; samesite=lax; httponly')); 756 | }); 757 | }); 758 | 759 | describe('when valid and beforeSave set', () => { 760 | it('should ignore session when uid changed', async () => { 761 | const app = new Koa(); 762 | 763 | app.keys = [ 'a', 'b' ]; 764 | app.use(session({ 765 | valid(ctx, sess) { 766 | return ctx.cookies.get('uid') === sess.uid; 767 | }, 768 | beforeSave(ctx, sess) { 769 | sess.uid = ctx.cookies.get('uid'); 770 | }, 771 | }, app)); 772 | app.use(async (ctx: Koa.Context) => { 773 | if (!ctx.session!.foo) { 774 | ctx.session!.foo = Date.now() + '|uid:' + ctx.cookies.get('uid'); 775 | } 776 | 777 | ctx.body = { 778 | foo: ctx.session!.foo, 779 | uid: ctx.cookies.get('uid'), 780 | }; 781 | }); 782 | 783 | const res = await request(app.callback()) 784 | .get('/') 785 | .set('Cookie', 'uid=123') 786 | .expect(200); 787 | 788 | const data = res.body; 789 | const cookies = res.get('Set-Cookie')!.join(';'); 790 | assert(cookies.includes('koa.sess=')); 791 | 792 | await request(app.callback()) 793 | .get('/') 794 | .set('Cookie', cookies + ';uid=123') 795 | .expect(200) 796 | .expect(data); 797 | 798 | // should ignore uid:123 session and create a new session for uid:456 799 | const res2 = await request(app.callback()) 800 | .get('/') 801 | .set('Cookie', cookies + ';uid=456') 802 | .expect(200); 803 | 804 | assert.equal(res2.body.uid, '456'); 805 | assert.notEqual(res2.body.foo, data.foo); 806 | }); 807 | }); 808 | 809 | describe('when options.encode and options.decode are functions', () => { 810 | describe('they are used to encode/decode stored cookie values', () => { 811 | it('should work', async () => { 812 | let encodeCallCount = 0; 813 | let decodeCallCount = 0; 814 | 815 | function encode(data: any) { 816 | ++encodeCallCount; 817 | return JSON.stringify({ enveloped: data }); 818 | } 819 | function decode(data: string) { 820 | ++decodeCallCount; 821 | return JSON.parse(data).enveloped; 822 | } 823 | 824 | const app = new Koa(); 825 | app.keys = [ 'a', 'b' ]; 826 | app.use(session({ 827 | encode, 828 | decode, 829 | }, app)); 830 | 831 | app.use(async (ctx: Koa.Context) => { 832 | ctx.session!.counter = (ctx.session!.counter || 0) + 1; 833 | ctx.body = ctx.session; 834 | return; 835 | }); 836 | 837 | const res = await request(app.callback()) 838 | .get('/') 839 | .expect(() => { assert(encodeCallCount > 0, 'encode was not called'); }) 840 | .expect(200); 841 | 842 | assert.equal(res.body.counter, 1, 'expected body to be equal to session.counter'); 843 | const cookies = res.get('Set-Cookie')!.join(';'); 844 | const res2 = await request(app.callback()) 845 | .get('/') 846 | .set('Cookie', cookies) 847 | .expect(() => { assert(decodeCallCount > 0, 'decode was not called'); }) 848 | .expect(200); 849 | 850 | assert.equal(res2.body.counter, 2); 851 | }); 852 | }); 853 | }); 854 | 855 | describe('when rolling set to true', () => { 856 | let app: Koa; 857 | before(() => { 858 | app = App({ rolling: true }); 859 | 860 | app.use(async (ctx: Koa.Context) => { 861 | if (ctx.path === '/set') ctx.session = { foo: 'bar' }; 862 | ctx.body = ctx.session; 863 | }); 864 | }); 865 | 866 | it('should not send set-cookie when session not exists', async () => { 867 | const res = await request(app.callback()) 868 | .get('/') 869 | .expect({}); 870 | 871 | assert.equal(res.headers['set-cookie'], undefined); 872 | }); 873 | 874 | it('should send set-cookie when session exists and not change', async () => { 875 | const res = await request(app.callback()) 876 | .get('/set') 877 | .expect({ foo: 'bar' }); 878 | 879 | assert.equal(res.get('Set-Cookie')!.length, 2); 880 | const cookie = res.get('Set-Cookie')!.join(';'); 881 | const res2 = await request(app.callback()) 882 | .get('/') 883 | .set('cookie', cookie) 884 | .expect({ foo: 'bar' }); 885 | assert.equal(res2.headers['set-cookie'].length, 2); 886 | }); 887 | }); 888 | 889 | describe('init multi session middleware', () => { 890 | it('should work', () => { 891 | const app = new Koa(); 892 | 893 | app.keys = [ 'a', 'b' ]; 894 | const s1 = session({}, app); 895 | const s2 = session({}, app); 896 | assert(s1); 897 | assert(s2); 898 | }); 899 | }); 900 | }); 901 | -------------------------------------------------------------------------------- /test/externalkey.test.ts: -------------------------------------------------------------------------------- 1 | import { strict as assert } from 'node:assert'; 2 | import Koa from 'koa'; 3 | import { ZodError } from 'zod'; 4 | import { request } from '@eggjs/supertest'; 5 | import session, { type CreateSessionOptions } from '../src/index.js'; 6 | import store from './store.js'; 7 | 8 | const TOKEN_KEY = 'User-Token'; 9 | 10 | function App(options: CreateSessionOptions = {}) { 11 | const app = new Koa(); 12 | app.keys = [ 'a', 'b' ]; 13 | options.store = store; 14 | options.externalKey = options.externalKey ?? { 15 | get: ctx => ctx.get(TOKEN_KEY), 16 | set: (ctx, value) => ctx.set(TOKEN_KEY, value), 17 | }; 18 | app.use(session(options, app)); 19 | return app; 20 | } 21 | 22 | describe('Koa Session External Key', () => { 23 | describe('when the external key set/get is invalid', () => { 24 | it('should throw a error', () => { 25 | assert.throws(() => { 26 | App({ 27 | externalKey: {} as any, 28 | }); 29 | }, err => { 30 | assert(err instanceof ZodError); 31 | assert.match(err.message, /externalKey/); 32 | return true; 33 | }); 34 | }); 35 | }); 36 | 37 | describe('custom get/set external key', () => { 38 | it('should still work', async () => { 39 | const app = App(); 40 | app.use(async function(ctx) { 41 | if (ctx.method === 'POST') { 42 | ctx.session.string = ';'; 43 | ctx.status = 204; 44 | assert(ctx.session.externalKey); 45 | } else { 46 | ctx.body = ctx.session.string; 47 | assert.equal(ctx.session.externalKey, ctx.get(TOKEN_KEY)); 48 | } 49 | }); 50 | const res = await request(app.callback()) 51 | .post('/') 52 | .expect(204); 53 | const token = res.get(TOKEN_KEY)!; 54 | await request(app.callback()) 55 | .get('/') 56 | .set(TOKEN_KEY as any, token) 57 | .expect(';'); 58 | }); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /test/index.test.ts: -------------------------------------------------------------------------------- 1 | import snapshot from 'snap-shot-it'; 2 | import { SessionOptions } from '../src/index.js'; 3 | 4 | describe('test/index.test.ts', () => { 5 | describe('SessionOptions schema', () => { 6 | it('should have a valid schema', () => { 7 | const parsed = SessionOptions.parse({}); 8 | snapshot(parsed); 9 | }); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /test/store.test.ts: -------------------------------------------------------------------------------- 1 | import { strict as assert } from 'node:assert'; 2 | import { scheduler } from 'node:timers/promises'; 3 | import Koa from 'koa'; 4 | import { request } from '@eggjs/supertest'; 5 | import { mm } from 'mm'; 6 | import session, { type CreateSessionOptions } from '../src/index.js'; 7 | import store from './store.js'; 8 | 9 | const inspect = Symbol.for('nodejs.util.inspect.custom'); 10 | 11 | function App(options: CreateSessionOptions = {}) { 12 | const app = new Koa(); 13 | app.keys = [ 'a', 'b' ]; 14 | options.store = options.store ?? store; 15 | app.use(session(options, app)); 16 | return app; 17 | } 18 | 19 | describe('Koa Session External Store', () => { 20 | let cookie: string; 21 | 22 | describe('when the session contains a ;', () => { 23 | it('should still work', async () => { 24 | const options: CreateSessionOptions = { store }; 25 | const app = App(options); 26 | 27 | app.use(async (ctx: Koa.Context) => { 28 | if (ctx.method === 'POST') { 29 | ctx.session.string = ';'; 30 | ctx.status = 204; 31 | } else { 32 | ctx.body = ctx.session.string; 33 | } 34 | }); 35 | 36 | const server = app.callback(); 37 | const res = await request(server) 38 | .post('/') 39 | .expect(204); 40 | 41 | const cookie = res.get('Set-Cookie')!; 42 | await request(server) 43 | .get('/') 44 | .set('Cookie', cookie.join(';')) 45 | .expect(';'); 46 | }); 47 | 48 | it('should disable store on options', async () => { 49 | const options: CreateSessionOptions = { store }; 50 | const app = App(options); 51 | 52 | app.use(async (ctx: Koa.Context) => { 53 | if (ctx.method === 'POST') { 54 | ctx.session.string = ';'; 55 | ctx.status = 204; 56 | } else { 57 | ctx.body = ctx.session.string ?? 'new session create'; 58 | } 59 | }); 60 | 61 | const server = app.callback(); 62 | const res = await request(server) 63 | .post('/') 64 | .expect(204); 65 | 66 | const cookie = res.get('Set-Cookie')!; 67 | await request(server) 68 | .get('/') 69 | .set('Cookie', cookie.join(';')) 70 | .expect(';'); 71 | 72 | options.store = undefined; 73 | await request(server) 74 | .get('/') 75 | .set('Cookie', cookie.join(';')) 76 | .expect('new session create'); 77 | }); 78 | }); 79 | 80 | describe('new session', () => { 81 | describe('when not accessed', () => { 82 | it('should not Set-Cookie', async () => { 83 | const app = App(); 84 | 85 | app.use(async (ctx: Koa.Context) => { 86 | ctx.body = 'greetings'; 87 | }); 88 | 89 | const res = await request(app.callback()) 90 | .get('/') 91 | .expect(200); 92 | 93 | assert(!res.headers['set-cookie']); 94 | }); 95 | }); 96 | 97 | describe('when accessed and not populated', () => { 98 | it('should not Set-Cookie', async () => { 99 | const app = App(); 100 | 101 | app.use(async (ctx: Koa.Context) => { 102 | ctx.session; 103 | ctx.body = 'greetings'; 104 | }); 105 | 106 | const res = await request(app.callback()) 107 | .get('/') 108 | .expect(200); 109 | 110 | assert(!res.headers['set-cookie']); 111 | }); 112 | }); 113 | 114 | describe('when populated', () => { 115 | it('should Set-Cookie', async () => { 116 | const app = App(); 117 | 118 | app.use(async (ctx: Koa.Context) => { 119 | ctx.session!.message = 'hello'; 120 | ctx.body = ''; 121 | }); 122 | 123 | const res = await request(app.callback()) 124 | .get('/') 125 | .expect('Set-Cookie', /koa\.sess/) 126 | .expect(200); 127 | 128 | cookie = res.get('Set-Cookie')!.join(';'); 129 | }); 130 | 131 | it('should not Set-Cookie', async () => { 132 | const app = App(); 133 | 134 | app.use(async (ctx: Koa.Context) => { 135 | ctx.body = ctx.session; 136 | }); 137 | 138 | const res = await request(app.callback()) 139 | .get('/') 140 | .expect(200); 141 | 142 | assert(!res.headers['set-cookie']); 143 | }); 144 | }); 145 | }); 146 | 147 | describe('saved session', () => { 148 | describe('when not accessed', () => { 149 | it('should not Set-Cookie', async () => { 150 | const app = App(); 151 | 152 | app.use(async (ctx: Koa.Context) => { 153 | ctx.body = 'aklsdjflasdjf'; 154 | }); 155 | 156 | const res = await request(app.callback()) 157 | .get('/') 158 | .set('Cookie', cookie) 159 | .expect(200); 160 | 161 | assert(!res.headers['set-cookie']); 162 | }); 163 | }); 164 | 165 | describe('when accessed but not changed', () => { 166 | it('should be the same session', async () => { 167 | const app = App(); 168 | 169 | app.use(async (ctx: Koa.Context) => { 170 | assert.equal(ctx.session!.message, 'hello'); 171 | ctx.body = 'aklsdjflasdjf'; 172 | }); 173 | 174 | await request(app.callback()) 175 | .get('/') 176 | .set('Cookie', cookie) 177 | .expect(200); 178 | }); 179 | 180 | it('should not Set-Cookie', async () => { 181 | const app = App(); 182 | 183 | app.use(async (ctx: Koa.Context) => { 184 | assert.equal(ctx.session!.message, 'hello'); 185 | ctx.body = 'aklsdjflasdjf'; 186 | }); 187 | 188 | const res = await request(app.callback()) 189 | .get('/') 190 | .set('Cookie', cookie) 191 | .expect(200); 192 | 193 | assert(!res.headers['set-cookie']); 194 | }); 195 | }); 196 | 197 | describe('when accessed and changed', () => { 198 | it('should Set-Cookie', async () => { 199 | const app = App(); 200 | 201 | app.use(async (ctx: Koa.Context) => { 202 | ctx.session!.money = '$$$'; 203 | ctx.body = 'aklsdjflasdjf'; 204 | }); 205 | 206 | await request(app.callback()) 207 | .get('/') 208 | .set('Cookie', cookie) 209 | .expect('Set-Cookie', /koa\.sess/) 210 | .expect(200); 211 | }); 212 | }); 213 | }); 214 | 215 | describe('when session is', () => { 216 | describe('null', () => { 217 | it('should expire the session', async () => { 218 | const app = App(); 219 | 220 | app.use(async (ctx: Koa.Context) => { 221 | ctx.session = null; 222 | ctx.body = 'asdf'; 223 | }); 224 | 225 | await request(app.callback()) 226 | .get('/') 227 | .expect('Set-Cookie', /koa\.sess/) 228 | .expect(200); 229 | }); 230 | }); 231 | 232 | describe('an empty object', () => { 233 | it('should not Set-Cookie', async () => { 234 | const app = App(); 235 | 236 | app.use(async (ctx: Koa.Context) => { 237 | ctx.session = {}; 238 | ctx.body = 'asdf'; 239 | }); 240 | 241 | const res = await request(app.callback()) 242 | .get('/') 243 | .expect(200); 244 | 245 | assert(!res.headers['set-cookie']); 246 | }); 247 | }); 248 | 249 | describe('an object', () => { 250 | it('should create a session', async () => { 251 | const app = App(); 252 | 253 | app.use(async (ctx: Koa.Context) => { 254 | ctx.session = { message: 'hello' }; 255 | ctx.body = 'asdf'; 256 | }); 257 | 258 | await request(app.callback()) 259 | .get('/') 260 | .expect('Set-Cookie', /koa\.sess/) 261 | .expect(200); 262 | }); 263 | }); 264 | 265 | describe('anything else', () => { 266 | it('should throw', async () => { 267 | const app = App(); 268 | 269 | app.use(async (ctx: Koa.Context) => { 270 | ctx.session = 'asdf'; 271 | }); 272 | 273 | await request(app.callback()) 274 | .get('/') 275 | .expect(500); 276 | }); 277 | }); 278 | }); 279 | 280 | describe('session', () => { 281 | describe('.inspect()', () => { 282 | it('should return session content', async () => { 283 | const app = App(); 284 | 285 | app.use(async (ctx: Koa.Context) => { 286 | ctx.session!.foo = 'bar'; 287 | ctx.body = ctx.session![inspect](); 288 | }); 289 | 290 | await request(app.callback()) 291 | .get('/') 292 | .expect('Set-Cookie', /koa\.sess=.+;/) 293 | .expect({ foo: 'bar' }) 294 | .expect(200); 295 | }); 296 | }); 297 | 298 | describe('.length', () => { 299 | it('should return session length', async () => { 300 | const app = App(); 301 | 302 | app.use(async (ctx: Koa.Context) => { 303 | ctx.session!.foo = 'bar'; 304 | ctx.body = String(ctx.session!.length); 305 | }); 306 | 307 | await request(app.callback()) 308 | .get('/') 309 | .expect('Set-Cookie', /koa\.sess=.+;/) 310 | .expect('1') 311 | .expect(200); 312 | }); 313 | }); 314 | 315 | describe('.populated', () => { 316 | it('should return session populated', async () => { 317 | const app = App(); 318 | 319 | app.use(async (ctx: Koa.Context) => { 320 | ctx.session!.foo = 'bar'; 321 | ctx.body = String(ctx.session!.populated); 322 | }); 323 | 324 | await request(app.callback()) 325 | .get('/') 326 | .expect('Set-Cookie', /koa\.sess=.+;/) 327 | .expect('true') 328 | .expect(200); 329 | }); 330 | }); 331 | 332 | describe('.save()', () => { 333 | it('should save session', async () => { 334 | const app = App(); 335 | 336 | app.use(async (ctx: Koa.Context) => { 337 | ctx.session!.save(); 338 | ctx.body = 'hello'; 339 | }); 340 | 341 | await request(app.callback()) 342 | .get('/') 343 | .expect('Set-Cookie', /koa\.sess=.+;/) 344 | .expect('hello') 345 | .expect(200); 346 | }); 347 | }); 348 | }); 349 | 350 | describe('when an error is thrown downstream and caught upstream', () => { 351 | it('should still save the session', async () => { 352 | const app = new Koa(); 353 | 354 | app.keys = [ 'a', 'b' ]; 355 | 356 | app.use(async (ctx: Koa.Context, next: Koa.Next) => { 357 | try { 358 | await next(); 359 | } catch (err: any) { 360 | ctx.status = err.status; 361 | ctx.body = err.message; 362 | } 363 | }); 364 | 365 | app.use(session({ store }, app)); 366 | 367 | app.use(async (ctx: Koa.Context, next: Koa.Next) => { 368 | ctx.session!.name = 'funny'; 369 | await next(); 370 | }); 371 | 372 | app.use(async (ctx: Koa.Context) => { 373 | ctx.throw(401); 374 | }); 375 | 376 | await request(app.callback()) 377 | .get('/') 378 | .expect('Set-Cookie', /koa\.sess/) 379 | .expect(401); 380 | }); 381 | }); 382 | 383 | describe('when maxAge present', () => { 384 | describe('and set to be a session cookie', () => { 385 | it('should not expire the session', async () => { 386 | const app = App({ maxAge: 'session' }); 387 | 388 | app.use(async (ctx: Koa.Context) => { 389 | if (ctx.method === 'POST') { 390 | ctx.session!.message = 'hi'; 391 | ctx.body = 200; 392 | return; 393 | } 394 | ctx.body = ctx.session!.message; 395 | }); 396 | 397 | const server = app.callback(); 398 | const res = await request(server) 399 | .post('/') 400 | .expect('Set-Cookie', /koa\.sess/); 401 | 402 | const cookie = res.get('Set-Cookie')!.join(';'); 403 | assert(!cookie.includes('expires=')); 404 | 405 | await request(server) 406 | .get('/') 407 | .set('cookie', cookie) 408 | .expect('hi'); 409 | }); 410 | }); 411 | 412 | describe('and not expire', () => { 413 | it('should not expire the session', async () => { 414 | const app = App({ maxAge: 100 }); 415 | 416 | app.use(async (ctx: Koa.Context) => { 417 | if (ctx.method === 'POST') { 418 | ctx.session!.message = 'hi'; 419 | ctx.body = 200; 420 | return; 421 | } 422 | ctx.body = ctx.session!.message; 423 | }); 424 | 425 | const server = app.callback(); 426 | const res = await request(server) 427 | .post('/') 428 | .expect('Set-Cookie', /koa\.sess/); 429 | 430 | const cookie = res.get('Set-Cookie')!.join(';'); 431 | 432 | await request(server) 433 | .get('/') 434 | .set('cookie', cookie) 435 | .expect('hi'); 436 | }); 437 | }); 438 | 439 | describe('and expired', () => { 440 | it('should expire the sess', async () => { 441 | const app = App({ maxAge: 100 }); 442 | app.on('session:expired', args => { 443 | assert(args.key.match(/^\w+-/)); 444 | assert(args.value); 445 | assert(args.ctx); 446 | }); 447 | 448 | app.use(async (ctx: Koa.Context) => { 449 | if (ctx.method === 'POST') { 450 | ctx.session!.message = 'hi'; 451 | ctx.status = 200; 452 | return; 453 | } 454 | 455 | ctx.body = ctx.session!.message || ''; 456 | }); 457 | 458 | const server = app.callback(); 459 | const res = await request(server) 460 | .post('/') 461 | .expect('Set-Cookie', /koa\.sess/); 462 | 463 | const cookie = res.get('Set-Cookie')!.join(';'); 464 | 465 | await new Promise(resolve => setTimeout(resolve, 200)); 466 | 467 | await request(server) 468 | .get('/') 469 | .set('cookie', cookie) 470 | .expect(''); 471 | }); 472 | }); 473 | }); 474 | 475 | describe('ctx.session.maxAge', () => { 476 | it('should return opt.maxAge', async () => { 477 | const app = App({ maxAge: 100 }); 478 | 479 | app.use(async (ctx: Koa.Context) => { 480 | ctx.body = ctx.session!.maxAge; 481 | }); 482 | 483 | await request(app.callback()) 484 | .get('/') 485 | .expect('100'); 486 | }); 487 | }); 488 | 489 | describe('ctx.session.maxAge=', () => { 490 | it('should set sessionOptions.maxAge', async () => { 491 | const app = App(); 492 | 493 | app.use(async (ctx: Koa.Context) => { 494 | ctx.session!.foo = 'bar'; 495 | ctx.session!.maxAge = 100; 496 | ctx.body = ctx.session!.foo; 497 | }); 498 | 499 | await request(app.callback()) 500 | .get('/') 501 | .expect('Set-Cookie', /expires=/) 502 | .expect(200); 503 | }); 504 | 505 | it('should save even session not change', async () => { 506 | const app = App(); 507 | 508 | app.use(async (ctx: Koa.Context) => { 509 | ctx.session!.maxAge = 100; 510 | ctx.body = ctx.session; 511 | }); 512 | 513 | await request(app.callback()) 514 | .get('/') 515 | .expect('Set-Cookie', /expires=/) 516 | .expect(200); 517 | }); 518 | 519 | it('should save when create session only with maxAge', async () => { 520 | const app = App(); 521 | 522 | app.use(async (ctx: Koa.Context) => { 523 | ctx.session = { maxAge: 100 }; 524 | ctx.body = ctx.session; 525 | }); 526 | 527 | await request(app.callback()) 528 | .get('/') 529 | .expect('Set-Cookie', /expires=/) 530 | .expect(200); 531 | }); 532 | }); 533 | 534 | describe('ctx.session.regenerate', () => { 535 | it('should change the session key, but not content', async () => { 536 | const app = App(); 537 | const message = 'hi'; 538 | 539 | app.use(async (ctx: Koa.Context, next: Koa.Next) => { 540 | ctx.session = { message: 'hi' }; 541 | await next(); 542 | }); 543 | 544 | app.use(async (ctx: Koa.Context, next: Koa.Next) => { 545 | const sessionKey = ctx.cookies.get('koa.sess'); 546 | if (sessionKey) { 547 | await ctx.session!.regenerate(); 548 | } 549 | await next(); 550 | }); 551 | 552 | app.use(async (ctx: Koa.Context) => { 553 | assert.equal(ctx.session!.message, message); 554 | ctx.body = ''; 555 | }); 556 | 557 | let res = await request(app.callback()) 558 | .get('/') 559 | .expect(200); 560 | 561 | const koaSession = res.get('Set-Cookie')!.join(';'); 562 | assert.match(koaSession, /koa\.sess=/); 563 | res = await request(app.callback()) 564 | .get('/') 565 | .set('Cookie', koaSession) 566 | .expect(200); 567 | 568 | const cookies = res.get('Set-Cookie')!.join(';'); 569 | assert.match(cookies, /koa\.sess=/); 570 | assert.notEqual(cookies, koaSession); 571 | }); 572 | }); 573 | 574 | describe('when store return empty', () => { 575 | it('should create new Session', async () => { 576 | const app = App({ signed: false }); 577 | 578 | app.use(async (ctx: Koa.Context) => { 579 | ctx.body = String(ctx.session!.isNew); 580 | }); 581 | 582 | app.on('session:missed', args => { 583 | assert.equal(args.key, 'invalid-key'); 584 | assert(args.ctx); 585 | }); 586 | 587 | await request(app.callback()) 588 | .get('/') 589 | .set('cookie', 'koa.sess=invalid-key') 590 | .expect('true') 591 | .expect(200); 592 | }); 593 | }); 594 | 595 | describe('when valid and beforeSave set', () => { 596 | it('should ignore session when uid changed', async () => { 597 | const app = new Koa(); 598 | 599 | app.keys = [ 'a', 'b' ]; 600 | app.use(session({ 601 | valid(ctx, sess) { 602 | return ctx.cookies.get('uid') === sess.uid; 603 | }, 604 | beforeSave(ctx, sess) { 605 | sess.uid = ctx.cookies.get('uid'); 606 | }, 607 | store, 608 | }, app)); 609 | 610 | app.use(async (ctx: Koa.Context) => { 611 | if (!ctx.session!.foo) { 612 | ctx.session!.foo = Date.now() + '|uid:' + ctx.cookies.get('uid'); 613 | } 614 | 615 | ctx.body = { 616 | foo: ctx.session!.foo, 617 | uid: ctx.cookies.get('uid'), 618 | }; 619 | }); 620 | 621 | app.on('session:invalid', args => { 622 | assert(args.key); 623 | assert(args.value); 624 | assert(args.ctx); 625 | }); 626 | 627 | let res = await request(app.callback()) 628 | .get('/') 629 | .set('Cookie', 'uid=123') 630 | .expect(200); 631 | 632 | const data = res.body; 633 | const cookies = res.get('Set-Cookie')!.join(';'); 634 | assert(cookies.includes('koa.sess=')); 635 | 636 | res = await request(app.callback()) 637 | .get('/') 638 | .set('Cookie', cookies + ';uid=123') 639 | .expect(200); 640 | 641 | assert.deepEqual(res.body, data); 642 | 643 | res = await request(app.callback()) 644 | .get('/') 645 | .set('Cookie', cookies + ';uid=456') 646 | .expect(200); 647 | 648 | assert.equal(res.body.uid, '456'); 649 | assert.notEqual(res.body.foo, data.foo); 650 | }); 651 | }); 652 | 653 | describe('ctx.session', () => { 654 | after(mm.restore); 655 | 656 | it('can be mocked', async () => { 657 | const app = App(); 658 | 659 | app.use(async (ctx: Koa.Context) => { 660 | ctx.body = ctx.session; 661 | }); 662 | 663 | mm(app.context, 'session', { 664 | foo: 'bar', 665 | }); 666 | 667 | await request(app.callback()) 668 | .get('/') 669 | .expect({ 670 | foo: 'bar', 671 | }) 672 | .expect(200); 673 | }); 674 | }); 675 | 676 | describe('when rolling set to true', () => { 677 | let app: Koa; 678 | before(() => { 679 | app = App({ rolling: true }); 680 | 681 | app.use(async (ctx: Koa.Context) => { 682 | if (ctx.path === '/set') ctx.session = { foo: 'bar' }; 683 | ctx.body = ctx.session; 684 | }); 685 | }); 686 | 687 | it('should not send set-cookie when session not exists', async () => { 688 | const res = await request(app.callback()) 689 | .get('/') 690 | .expect({}); 691 | 692 | assert(!res.headers['set-cookie']); 693 | }); 694 | 695 | it('should send set-cookie when session exists and not change', async () => { 696 | let res = await request(app.callback()) 697 | .get('/set') 698 | .expect({ foo: 'bar' }); 699 | 700 | assert.equal(res.headers['set-cookie'].length, 2); 701 | const cookie = res.get('Set-Cookie')!.join(';'); 702 | 703 | res = await request(app.callback()) 704 | .get('/') 705 | .set('cookie', cookie) 706 | .expect({ foo: 'bar' }); 707 | 708 | assert.equal(res.headers['set-cookie'].length, 2); 709 | }); 710 | }); 711 | 712 | describe('when prefix present', () => { 713 | it('should still work', async () => { 714 | const app = App({ prefix: 'sess:' }); 715 | 716 | app.use(async (ctx: Koa.Context) => { 717 | if (ctx.method === 'POST') { 718 | ctx.session!.string = ';'; 719 | ctx.status = 204; 720 | } else { 721 | ctx.body = ctx.session!.string; 722 | } 723 | }); 724 | 725 | const server = app.callback(); 726 | const res = await request(server) 727 | .post('/') 728 | .expect(204); 729 | 730 | const cookie = res.get('Set-Cookie')!; 731 | assert(cookie.join().includes('koa.sess=sess:')); 732 | 733 | await request(server) 734 | .get('/') 735 | .set('Cookie', cookie.join(';')) 736 | .expect(';'); 737 | }); 738 | }); 739 | 740 | describe('when renew set to true', () => { 741 | let app: Koa; 742 | before(() => { 743 | app = App({ renew: true, maxAge: 2000 }); 744 | 745 | app.use(async (ctx: Koa.Context) => { 746 | if (ctx.path === '/set') ctx.session = { foo: 'bar' }; 747 | ctx.body = ctx.session; 748 | }); 749 | }); 750 | 751 | it('should not send set-cookie when session not exists', async () => { 752 | const res = await request(app.callback()) 753 | .get('/') 754 | .expect({}); 755 | 756 | assert(!res.headers['set-cookie']); 757 | }); 758 | 759 | it('should send set-cookie when session near expire and not change', async () => { 760 | let res = await request(app.callback()) 761 | .get('/set') 762 | .expect({ foo: 'bar' }); 763 | 764 | assert.equal(res.get('Set-Cookie')!.length, 2); 765 | const cookie = res.get('Set-Cookie')!.join(';'); 766 | 767 | await scheduler.wait(1200); 768 | 769 | res = await request(app.callback()) 770 | .get('/') 771 | .set('cookie', cookie) 772 | .expect({ foo: 'bar' }); 773 | 774 | assert.equal(res.headers['set-cookie'].length, 2); 775 | }); 776 | 777 | it('should not send set-cookie when session not near expire and not change', async () => { 778 | let res = await request(app.callback()) 779 | .get('/set') 780 | .expect({ foo: 'bar' }); 781 | 782 | assert.equal(res.headers['set-cookie'].length, 2); 783 | const cookie = res.get('Set-Cookie')!.join(';'); 784 | 785 | await scheduler.wait(500); 786 | 787 | res = await request(app.callback()) 788 | .get('/') 789 | .set('cookie', cookie) 790 | .expect({ foo: 'bar' }); 791 | 792 | assert(!res.headers['set-cookie']); 793 | }); 794 | }); 795 | 796 | describe('when get session before middleware', () => { 797 | it('should return empty session', async () => { 798 | const app = new Koa(); 799 | app.keys = [ 'a', 'b' ]; 800 | const options = { store }; 801 | 802 | app.use(async (ctx: Koa.Context, next: Koa.Next) => { 803 | assert(ctx.session); 804 | ctx.session.foo = '123'; 805 | await next(); 806 | }); 807 | 808 | app.use(session(options, app)); 809 | 810 | app.use(async (ctx: Koa.Context) => { 811 | if (ctx.path === '/set') ctx.session = { foo: 'bar' }; 812 | ctx.body = ctx.session; 813 | }); 814 | 815 | let res = await request(app.callback()) 816 | .get('/') 817 | .expect({}); 818 | 819 | res = await request(app.callback()) 820 | .get('/set') 821 | .expect({ foo: 'bar' }); 822 | 823 | const cookie = res.get('Set-Cookie')!.join(';'); 824 | await scheduler.wait(1200); 825 | 826 | res = await request(app.callback()) 827 | .get('/') 828 | .set('cookie', cookie) 829 | .expect({ foo: 'bar' }); 830 | }); 831 | }); 832 | }); 833 | -------------------------------------------------------------------------------- /test/store.ts: -------------------------------------------------------------------------------- 1 | const sessions: Record = {}; 2 | 3 | export default { 4 | async get(key: string) { 5 | return sessions[key]; 6 | }, 7 | 8 | async set(key: string, value: unknown) { 9 | sessions[key] = value; 10 | }, 11 | 12 | async destroy(key: string) { 13 | sessions[key] = undefined; 14 | }, 15 | }; 16 | -------------------------------------------------------------------------------- /test/store_with_ctx.test.ts: -------------------------------------------------------------------------------- 1 | import { strict as assert } from 'node:assert'; 2 | import Koa from 'koa'; 3 | import { request } from '@eggjs/supertest'; 4 | import session, { type CreateSessionOptions } from '../src/index.js'; 5 | import store from './store_with_ctx.js'; 6 | 7 | function App(options: CreateSessionOptions = {}) { 8 | const app = new Koa(); 9 | app.keys = [ 'a', 'b' ]; 10 | options.store = store; 11 | app.use(async (ctx, next) => { 12 | await next(); 13 | ctx.body = ctx.state.test === undefined ? 'undefined' : ctx.state.test; 14 | }); 15 | 16 | app.use(session(options, app)); 17 | return app; 18 | } 19 | 20 | describe('Koa Session External Store methods can access Koa context', () => { 21 | let cookie: string; 22 | 23 | describe('new session', () => { 24 | describe('when not accessed', () => { 25 | it('should not set ctx.state.test variable', async () => { 26 | const app = App(); 27 | 28 | await request(app.callback()) 29 | .get('/') 30 | .expect('undefined'); 31 | }); 32 | }); 33 | 34 | describe('when populated', () => { 35 | it('should set ctx.state.test variable', async () => { 36 | const app = App(); 37 | 38 | app.use(async ctx => { 39 | if (ctx.path === '/set') ctx.session = { foo: 'bar' }; 40 | }); 41 | 42 | const res = await request(app.callback()) 43 | .get('/set') 44 | .expect(200); 45 | cookie = res.get('Set-Cookie')!.join(';'); 46 | assert.equal(res.text, 'set'); 47 | }); 48 | }); 49 | 50 | describe('when accessed', () => { 51 | it('should access ctx.state.test variable', async () => { 52 | const app = App(); 53 | 54 | await request(app.callback()) 55 | .get('/') 56 | .set('Cookie', cookie) 57 | .expect('get'); 58 | }); 59 | }); 60 | 61 | describe('session destroyed', () => { 62 | it('should access ctx.state.test variable', async () => { 63 | const app = App(); 64 | 65 | app.use(async ctx => { 66 | if (ctx.path === '/destroy') { 67 | ctx.session = null; 68 | } 69 | }); 70 | 71 | await request(app.callback()) 72 | .get('/destroy') 73 | .set('Cookie', cookie) 74 | .expect('destroyed'); 75 | }); 76 | }); 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /test/store_with_ctx.ts: -------------------------------------------------------------------------------- 1 | const sessions: Record = {}; 2 | 3 | export default { 4 | async get(key: string, _maxAge: number, options: any) { 5 | // check access to options.ctx 6 | options.ctx.state.test = 'get'; 7 | return sessions[key]; 8 | }, 9 | 10 | async set(key: string, sess: Record, _maxAge: number, options: any) { 11 | // check access to options.ctx 12 | options.ctx.state.test = 'set'; 13 | sessions[key] = sess; 14 | }, 15 | 16 | async destroy(key: string, options: any) { 17 | // check access to options.ctx 18 | options.ctx.state.test = 'destroyed'; 19 | sessions[key] = undefined; 20 | }, 21 | }; 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@eggjs/tsconfig", 3 | "compilerOptions": { 4 | "strict": true, 5 | "noImplicitAny": true, 6 | "target": "ES2022", 7 | "module": "NodeNext", 8 | "moduleResolution": "NodeNext" 9 | } 10 | } 11 | --------------------------------------------------------------------------------