├── .gitignore ├── .jshintrc ├── .travis.yml ├── ChangeLog ├── LICENSE ├── README.md ├── lib └── client-sessions.js ├── package.json └── test └── all-test.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *~ -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "bitwise": false, 3 | "boss": true, 4 | "browser": true, 5 | "camelcase": true, 6 | "curly": true, 7 | "esnext": true, 8 | "eqeqeq": true, 9 | "eqnull": true, 10 | "expr": true, 11 | "forin": false, 12 | "indent": 2, 13 | "latedef": true, 14 | "laxbreak": true, 15 | "laxcomma": true, 16 | "maxcomplexity": 10, 17 | "maxlen": 80, 18 | "maxerr": 100, 19 | "node": true, 20 | "noarg": true, 21 | "passfail": false, 22 | "shadow": true, 23 | "strict": false, 24 | "supernew": false, 25 | "trailing": true, 26 | "undef": true, 27 | "unused": true 28 | } 29 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "4" 4 | - "6" 5 | - stable 6 | -------------------------------------------------------------------------------- /ChangeLog: -------------------------------------------------------------------------------- 1 | 0.7.0 - 2 | * add `session.destroy()` alias for `session.reset()` 3 | * invalid base64 will return undefined instead of throw 4 | 5 | 0.6.0 - 6 | * add secret can also be a Buffer 7 | * add `encryptonKey` and `signatureKey` options, to be used instead of `secret` 8 | * add `encryptionAlgorithm` and `signatureAlgorithm` options 9 | * fix zero out buffers during encryption 10 | * docs for new crypto options 11 | * docs describing how crypto is used 12 | 13 | 0.5.0 - 14 | * fix dirty checking for nested objects. req.session.foo.bar = 'baz' now works. 15 | * fix setting req.session = someObj will update from someObj 16 | * removed usage of Proxy, now uses getters/setters 17 | 18 | 0.4.1 - 19 | * change to writeHead, no longer depending on connect's res.on('header') event 20 | * fix checking existence of res.socket before checking res.socket.encrypted 21 | * documentation added js syntax highlighting 22 | 23 | 0.4.0 - 24 | * add activeDuration with default to 5 minutes 25 | * add checking for native Proxy before using node-proxy 26 | * add cookie.ephemeral option, default false 27 | * add constant-time check 28 | * adds self-aware check. wont override req.session if already exists 29 | * fix wrong handled of utf8 replacement character 30 | * fix http expiry of cookie to match duration 31 | * fix updating cookie expiry whenever duration/createdAt changes 32 | 33 | 0.3.1 - 34 | * documentation update 35 | * support opt.requestKey to allow usage of a key other than cookie name 36 | 37 | 0.1.0 - 38 | * node 0.10.x support 39 | * fix bug in .reset() - session would load from an existing cookie - thanks @khmelichek 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![build status](https://secure.travis-ci.org/mozilla/node-client-sessions.png)](http://travis-ci.org/mozilla/node-client-sessions) 2 | 3 | client-sessions is connect middleware that implements sessions in encrypted tamper-free cookies. For a complete introduction to encrypted client side sessions, refer to [Francois Marier's blog post on the subject][]; 4 | 5 | [Francois Marier's blog post on the subject]: https://hacks.mozilla.org/2012/12/using-secure-client-side-sessions-to-build-simple-and-scalable-node-js-applications-a-node-js-holiday-season-part-3/ 6 | 7 | **NOTE:** It is not recommended using both this middleware and connect's built-in session middleware. 8 | 9 | ## Installation 10 | `npm install client-sessions` 11 | 12 | ## Usage 13 | 14 | Basic usage: 15 | 16 | ```js 17 | var sessions = require("client-sessions"); 18 | app.use(sessions({ 19 | cookieName: 'mySession', // cookie name dictates the key name added to the request object 20 | secret: 'blargadeeblargblarg', // should be a large unguessable string 21 | duration: 24 * 60 * 60 * 1000, // how long the session will stay valid in ms 22 | activeDuration: 1000 * 60 * 5 // if expiresIn < activeDuration, the session will be extended by activeDuration milliseconds 23 | })); 24 | 25 | app.use(function(req, res, next) { 26 | if (req.mySession.seenyou) { 27 | res.setHeader('X-Seen-You', 'true'); 28 | } else { 29 | // setting a property will automatically cause a Set-Cookie response 30 | // to be sent 31 | req.mySession.seenyou = true; 32 | res.setHeader('X-Seen-You', 'false'); 33 | } 34 | }); 35 | ``` 36 | 37 | You can control more specific cookie behavior during setup: 38 | 39 | ```js 40 | app.use(sessions({ 41 | cookieName: 'mySession', // cookie name dictates the key name added to the request object 42 | secret: 'blargadeeblargblarg', // should be a large unguessable string 43 | duration: 24 * 60 * 60 * 1000, // how long the session will stay valid in ms 44 | cookie: { 45 | path: '/api', // cookie will only be sent to requests under '/api' 46 | maxAge: 60000, // duration of the cookie in milliseconds, defaults to duration above 47 | ephemeral: false, // when true, cookie expires when the browser closes 48 | httpOnly: true, // when true, cookie is not accessible from javascript 49 | secure: false // when true, cookie will only be sent over SSL. use key 'secureProxy' instead if you handle SSL not in your node process 50 | } 51 | })); 52 | ``` 53 | 54 | You can have multiple cookies: 55 | 56 | ```js 57 | // a 1 week session 58 | app.use(sessions({ 59 | cookieName: 'shopping_cart', 60 | secret: 'first secret', 61 | duration: 7 * 24 * 60 * 60 * 1000 62 | })); 63 | 64 | // a 2 hour encrypted session 65 | app.use(sessions({ 66 | cookieName: 'authenticated', 67 | secret: 'first secret', 68 | duration: 2 * 60 * 60 * 1000 69 | })); 70 | ``` 71 | 72 | In this example, there's a 2 hour authentication session, but shopping carts persist for a week. 73 | 74 | Finally, you can use requestKey to force the name where information can be accessed on the request object. 75 | 76 | ```js 77 | var sessions = require("client-sessions"); 78 | app.use(sessions({ 79 | cookieName: 'mySession', 80 | requestKey: 'forcedSessionKey', // requestKey overrides cookieName for the key name added to the request object. 81 | secret: 'blargadeeblargblarg', // should be a large unguessable string or Buffer 82 | duration: 24 * 60 * 60 * 1000, // how long the session will stay valid in ms 83 | })); 84 | 85 | app.use(function(req, res, next) { 86 | // requestKey forces the session information to be 87 | // accessed via forcedSessionKey 88 | if (req.forcedSessionKey.seenyou) { 89 | res.setHeader('X-Seen-You', 'true'); 90 | } 91 | next(); 92 | }); 93 | ``` 94 | 95 | ## Cryptography 96 | 97 | A pair of encryption and signature keys are derived from the `secret` option 98 | via HMAC-SHA-256; the `secret` isn't used directly to encrypt or compute the 99 | MAC. 100 | 101 | The key-derivation function, in pseudocode: 102 | 103 | ```text 104 | encKey := HMAC-SHA-256(secret, 'cookiesession-encryption'); 105 | sigKey := HMAC-SHA-256(secret, 'cookiesession-signature'); 106 | ``` 107 | 108 | The **AES-256-CBC** cipher is used to encrypt the session contents, with an 109 | **HMAC-SHA-256** authentication tag (via **Encrypt-then-Mac** composition). A 110 | random 128-bit Initialization Vector (IV) is generated for each encryption 111 | operation (this is the AES block size regardless of the key size). The 112 | CBC-mode input is padded with the usual PKCS#5 scheme. 113 | 114 | In pseudocode, the encryption looks like the following, with `||` denoting 115 | concatenation. The `createdAt` and `duration` parameters are decimal strings. 116 | 117 | ```text 118 | sessionText := cookieName || '=' || sessionJson 119 | iv := secureRandom(16 bytes) 120 | ciphertext := AES-256-CBC(encKey, iv, sessionText) 121 | payload := iv || '.' || ciphertext || '.' || createdAt || '.' || duration 122 | hmac := HMAC-SHA-256(sigKey, payload) 123 | cookie := base64url(iv) || '.' || 124 | base64url(ciphertext) || '.' || 125 | createdAt || '.' || 126 | duration || '.' || 127 | base64url(hmac) 128 | ``` 129 | 130 | For decryption, a constant-time equality operation is used to verify the HMAC 131 | output to avoid the plausible timing attack. 132 | 133 | ### Advanced Cryptographic Options 134 | 135 | The defaults are secure, but may not suit your requirements. Some example scenarios: 136 | - You want to use randomly-generated keys instead of using the key-derivation 137 | function used in this module. 138 | - AES-256 is overkill for the type of data you store in the session (e.g. not 139 | personally-identifiable or sensitive) and you'd like to trade-off decreasing 140 | the security level for CPU economy. 141 | - SHA-256 is maybe too weak for your application and you want to have more 142 | MAC security by using SHA-512, which grows the size of your cookies slightly. 143 | 144 | If the defaults don't suit your needs, you can customize client-sessions. 145 | **Beware: Changing keys and/or algorithms will make previously-generated 146 | Cookies invalid!** 147 | 148 | #### Configuring Keys 149 | 150 | To configure independent encryption and signature (HMAC) keys: 151 | 152 | ```js 153 | app.use(sessions({ 154 | encryptionKey: loadFromKeyStore('session-encryption-key'), 155 | signatureKey: loadFromKeyStore('session-signature-key'), 156 | // ... other options discussed above ... 157 | })); 158 | ``` 159 | 160 | #### Configuring Algorithms 161 | 162 | To specify custom algorithms and keys: 163 | 164 | ```js 165 | app.use(sessions({ 166 | // use WEAKER-than-default encryption: 167 | encryptionAlgorithm: 'aes128', 168 | encryptionKey: loadFromKeyStore('session-encryption-key'), 169 | // use a SHORTER-than-default MAC: 170 | signatureAlgorithm: 'sha256-drop128', 171 | signatureKey: loadFromKeyStore('session-signature-key'), 172 | // ... other options discussed above ... 173 | })); 174 | ``` 175 | 176 | #### Encryption Algorithms 177 | 178 | Supported CBC-mode `encryptionAlgorithm`s (and key length requirements): 179 | 180 | | Cipher | Key length | 181 | | ------ | ---------- | 182 | | aes128 | 16 bytes | 183 | | aes192 | 24 bytes | 184 | | aes256 | 32 bytes | 185 | 186 | These key lengths are exactly as required by the [Advanced Encryption 187 | Standard](https://en.wikipedia.org/wiki/Advanced_Encryption_Standard). 188 | 189 | #### Signature (HMAC) Algorithms 190 | 191 | Supported HMAC `signatureAlgorithm`s (and key length requirements): 192 | 193 | | HMAC | Minimum Key Length | Maximum Key Length | 194 | | -------------- | ------------------ | ------------------ | 195 | | sha256 | 32 bytes | 64 bytes | 196 | | sha256-drop128 | 32 bytes | 64 bytes | 197 | | sha384 | 48 bytes | 128 bytes | 198 | | sha384-drop192 | 48 bytes | 128 bytes | 199 | | sha512 | 64 bytes | 128 bytes | 200 | | sha512-drop256 | 64 bytes | 128 bytes | 201 | 202 | The HMAC key length requirements are derived from [RFC 2104 section 203 | 3](https://tools.ietf.org/html/rfc2104#section-3). The maximum key length can 204 | be exceeded, but it doesn't increase the security of the signature. 205 | 206 | The `-dropN` algorithms discard the latter half of the HMAC output, which 207 | provides some additional protection against SHA2 length-extension attacks on 208 | top of HMAC. The same technique is used in the upcoming [JSON Web Algorithms 209 | `AES_CBC_HMAC_SHA2` authenticated 210 | cipher](http://tools.ietf.org/html/draft-ietf-jose-json-web-algorithms-19#section-5.2). 211 | 212 | #### Generating Keys 213 | 214 | One can easily generate both AES and HMAC-SHA2 keys via command line: `openssl 215 | rand -base64 32` for a 32-byte (256-bit) key. It's easy to then parse that 216 | output into a `Buffer`: 217 | 218 | ```js 219 | function loadKeyFromStore(name) { 220 | var text = myConfig.keys[name]; 221 | return Buffer.from(text, 'base64'); 222 | } 223 | ``` 224 | 225 | #### Key Constraints 226 | 227 | If you specify `encryptionKey` or `signatureKey`, you must supply the other as 228 | well. 229 | 230 | The following constraints must be met or an `Error` will be thrown: 231 | 232 | 1. both keys must be `Buffer`s. 233 | 2. the keys must be _different_. 234 | 3. the encryption key are _exactly_ the length required (see above). 235 | 4. the signature key has _at least_ the length required (see above). 236 | 237 | Based on the above, please note that if you specify a `secret` _and_ a 238 | `signatureAlgorithm`, you need to use `sha256` or `sha256-drop128`. 239 | 240 | ## License 241 | 242 | > This Source Code Form is subject to the terms of the Mozilla Public 243 | > License, v. 2.0. If a copy of the MPL was not distributed with this 244 | > file, You can obtain one at http://mozilla.org/MPL/2.0/. 245 | -------------------------------------------------------------------------------- /lib/client-sessions.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | const Cookies = require("cookies"); 6 | 7 | const crypto = require("crypto"); 8 | const util = require("util"); 9 | 10 | 11 | const COOKIE_NAME_SEP = '='; 12 | const ACTIVE_DURATION = 1000 * 60 * 5; 13 | 14 | const KDF_ENC = 'cookiesession-encryption'; 15 | const KDF_MAC = 'cookiesession-signature'; 16 | 17 | /* map from cipher algorithm to exact key byte length */ 18 | const ENCRYPTION_ALGORITHMS = { 19 | aes128: 16, // implicit CBC mode 20 | aes192: 24, 21 | aes256: 32 22 | }; 23 | const DEFAULT_ENCRYPTION_ALGO = 'aes256'; 24 | 25 | /* map from hmac algorithm to _minimum_ key byte length */ 26 | const SIGNATURE_ALGORITHMS = { 27 | 'sha256': 32, 28 | 'sha256-drop128': 32, 29 | 'sha384': 48, 30 | 'sha384-drop192': 48, 31 | 'sha512': 64, 32 | 'sha512-drop256': 64 33 | }; 34 | const DEFAULT_SIGNATURE_ALGO = 'sha256'; 35 | 36 | function isObject(val) { 37 | return Object.prototype.toString.call(val) === '[object Object]'; 38 | } 39 | 40 | function base64urlencode(arg) { 41 | var s = arg.toString('base64'); 42 | s = s.split('=')[0]; // Remove any trailing '='s 43 | s = s.replace(/\+/g, '-'); // 62nd char of encoding 44 | s = s.replace(/\//g, '_'); // 63rd char of encoding 45 | // TODO optimize this; we can do much better 46 | return s; 47 | } 48 | 49 | function base64urldecode(arg) { 50 | var s = arg; 51 | s = s.replace(/-/g, '+'); // 62nd char of encoding 52 | s = s.replace(/_/g, '/'); // 63rd char of encoding 53 | switch (s.length % 4) { // Pad with trailing '='s 54 | case 0: 55 | break; // No pad chars in this case 56 | case 2: 57 | s += "=="; 58 | break; // Two pad chars 59 | case 3: 60 | s += "="; 61 | break; // One pad char 62 | default: 63 | throw new Error("Illegal base64url string!"); 64 | } 65 | return Buffer.from(s, 'base64'); // Standard base64 decoder 66 | } 67 | 68 | function forceBuffer(binaryOrBuffer) { 69 | if (Buffer.isBuffer(binaryOrBuffer)) { 70 | return binaryOrBuffer; 71 | } else { 72 | return Buffer.from(binaryOrBuffer, 'binary'); 73 | } 74 | } 75 | 76 | function deriveKey(master, type) { 77 | // eventually we want to use HKDF. For now we'll do something simpler. 78 | var hmac = crypto.createHmac('sha256', master); 79 | hmac.update(type); 80 | return forceBuffer(hmac.digest()); 81 | } 82 | 83 | function setupKeys(opts) { 84 | // derive two keys, one for signing one for encrypting, from the secret. 85 | if (!opts.encryptionKey) { 86 | opts.encryptionKey = deriveKey(opts.secret, KDF_ENC); 87 | } 88 | 89 | if (!opts.signatureKey) { 90 | opts.signatureKey = deriveKey(opts.secret, KDF_MAC); 91 | } 92 | 93 | if (!opts.signatureAlgorithm) { 94 | opts.signatureAlgorithm = DEFAULT_SIGNATURE_ALGO; 95 | } 96 | 97 | if (!opts.encryptionAlgorithm) { 98 | opts.encryptionAlgorithm = DEFAULT_ENCRYPTION_ALGO; 99 | } 100 | } 101 | 102 | function keyConstraints(opts) { 103 | if (!Buffer.isBuffer(opts.encryptionKey)) { 104 | throw new Error('encryptionKey must be a Buffer'); 105 | } 106 | if (!Buffer.isBuffer(opts.signatureKey)) { 107 | throw new Error('signatureKey must be a Buffer'); 108 | } 109 | 110 | if (constantTimeEquals(opts.encryptionKey, opts.signatureKey)) { 111 | throw new Error('Encryption and Signature keys must be different'); 112 | } 113 | 114 | var encAlgo = opts.encryptionAlgorithm; 115 | var required = ENCRYPTION_ALGORITHMS[encAlgo]; 116 | if (opts.encryptionKey.length !== required) { 117 | throw new Error( 118 | 'Encryption Key for '+encAlgo+' must be exactly '+required+' bytes '+ 119 | '('+(required*8)+' bits)' 120 | ); 121 | } 122 | 123 | var sigAlgo = opts.signatureAlgorithm; 124 | var minimum = SIGNATURE_ALGORITHMS[sigAlgo]; 125 | if (opts.signatureKey.length < minimum) { 126 | throw new Error( 127 | 'Encryption Key for '+sigAlgo+' must be at least '+minimum+' bytes '+ 128 | '('+(minimum*8)+' bits)' 129 | ); 130 | } 131 | } 132 | 133 | function constantTimeEquals(a, b) { 134 | // Ideally this would be a native function, so it's less sensitive to how the 135 | // JS engine might optimize. 136 | if (a.length !== b.length) { 137 | return false; 138 | } 139 | var ret = 0; 140 | for (var i = 0; i < a.length; i++) { 141 | ret |= a.readUInt8(i) ^ b.readUInt8(i); 142 | } 143 | return ret === 0; 144 | } 145 | 146 | // it's good cryptographic pracitice to not leave buffers with sensitive 147 | // contents hanging around. 148 | function zeroBuffer(buf) { 149 | for (var i = 0; i < buf.length; i++) { 150 | buf[i] = 0; 151 | } 152 | return buf; 153 | } 154 | 155 | function hmacInit(algo, key) { 156 | var match = algo.match(/^([^-]+)(?:-drop(\d+))?$/); 157 | var baseAlg = match[1]; 158 | var drop = match[2] ? parseInt(match[2], 10) : 0; 159 | 160 | var hmacAlg = crypto.createHmac(baseAlg, key); 161 | var origDigest = hmacAlg.digest; 162 | 163 | if (drop === 0) { 164 | // Before 0.10, crypto returns binary-encoded strings. Remove when dropping 165 | // 0.8 support. 166 | hmacAlg.digest = function() { 167 | return forceBuffer(origDigest.call(this)); 168 | }; 169 | } else { 170 | var N = drop / 8; // bits to bytes 171 | hmacAlg.digest = function dropN() { 172 | var result = forceBuffer(origDigest.call(this)); 173 | // Throw away the second half of the 512-bit result, leaving the first 174 | // 256-bits. 175 | var truncated = Buffer.alloc(N); 176 | result.copy(truncated, 0, 0, N); 177 | zeroBuffer(result); 178 | return truncated; 179 | }; 180 | } 181 | 182 | return hmacAlg; 183 | } 184 | 185 | function computeHmac(opts, iv, ciphertext, duration, createdAt) { 186 | var hmacAlg = hmacInit(opts.signatureAlgorithm, opts.signatureKey); 187 | 188 | hmacAlg.update(iv); 189 | hmacAlg.update("."); 190 | hmacAlg.update(ciphertext); 191 | hmacAlg.update("."); 192 | hmacAlg.update(createdAt.toString()); 193 | hmacAlg.update("."); 194 | hmacAlg.update(duration.toString()); 195 | 196 | return hmacAlg.digest(); 197 | } 198 | 199 | function encode(opts, content, duration, createdAt){ 200 | // format will be: 201 | // iv.ciphertext.createdAt.duration.hmac 202 | 203 | if (!opts.cookieName) { 204 | throw new Error('cookieName option required'); 205 | } else if (String(opts.cookieName).indexOf(COOKIE_NAME_SEP) !== -1) { 206 | throw new Error('cookieName cannot include "="'); 207 | } 208 | 209 | setupKeys(opts); 210 | 211 | duration = duration || 24*60*60*1000; 212 | createdAt = createdAt || new Date().getTime(); 213 | 214 | // generate iv 215 | var iv = crypto.randomBytes(16); 216 | 217 | // encrypt with encryption key 218 | var plaintext = Buffer.from( 219 | opts.cookieName + COOKIE_NAME_SEP + JSON.stringify(content), 220 | 'utf8' 221 | ); 222 | var cipher = crypto.createCipheriv( 223 | opts.encryptionAlgorithm, 224 | opts.encryptionKey, 225 | iv 226 | ); 227 | 228 | var ciphertextStart = forceBuffer(cipher.update(plaintext)); 229 | zeroBuffer(plaintext); 230 | var ciphertextEnd = forceBuffer(cipher.final()); 231 | var ciphertext = Buffer.concat([ciphertextStart, ciphertextEnd]); 232 | zeroBuffer(ciphertextStart); 233 | zeroBuffer(ciphertextEnd); 234 | 235 | // hmac it 236 | var hmac = computeHmac(opts, iv, ciphertext, duration, createdAt); 237 | 238 | var result = [ 239 | base64urlencode(iv), 240 | base64urlencode(ciphertext), 241 | createdAt, 242 | duration, 243 | base64urlencode(hmac) 244 | ].join('.'); 245 | 246 | zeroBuffer(iv); 247 | zeroBuffer(ciphertext); 248 | zeroBuffer(hmac); 249 | 250 | return result; 251 | } 252 | 253 | function decode(opts, content) { 254 | if (!opts.cookieName) { 255 | throw new Error("cookieName option required"); 256 | } 257 | 258 | // stop at any time if there's an issue 259 | var components = content.split("."); 260 | if (components.length !== 5) { 261 | return; 262 | } 263 | 264 | setupKeys(opts); 265 | 266 | var iv; 267 | var ciphertext; 268 | var hmac; 269 | 270 | try { 271 | iv = base64urldecode(components[0]); 272 | ciphertext = base64urldecode(components[1]); 273 | hmac = base64urldecode(components[4]); 274 | } catch (ignored) { 275 | cleanup(); 276 | return; 277 | } 278 | 279 | var createdAt = parseInt(components[2], 10); 280 | var duration = parseInt(components[3], 10); 281 | 282 | function cleanup() { 283 | if (iv) { 284 | zeroBuffer(iv); 285 | } 286 | 287 | if (ciphertext) { 288 | zeroBuffer(ciphertext); 289 | } 290 | 291 | if (hmac) { 292 | zeroBuffer(hmac); 293 | } 294 | 295 | if (expectedHmac) { // declared below 296 | zeroBuffer(expectedHmac); 297 | } 298 | } 299 | 300 | // make sure IV is right length 301 | if (iv.length !== 16) { 302 | cleanup(); 303 | return; 304 | } 305 | 306 | // check hmac 307 | var expectedHmac = computeHmac(opts, iv, ciphertext, duration, createdAt); 308 | 309 | if (!constantTimeEquals(hmac, expectedHmac)) { 310 | cleanup(); 311 | return; 312 | } 313 | 314 | // decrypt 315 | var cipher = crypto.createDecipheriv( 316 | opts.encryptionAlgorithm, 317 | opts.encryptionKey, 318 | iv 319 | ); 320 | var plaintext = cipher.update(ciphertext, 'binary', 'utf8'); 321 | plaintext += cipher.final('utf8'); 322 | 323 | var cookieName = plaintext.substring(0, plaintext.indexOf(COOKIE_NAME_SEP)); 324 | if (cookieName !== opts.cookieName) { 325 | cleanup(); 326 | return; 327 | } 328 | 329 | var result; 330 | try { 331 | result = { 332 | content: JSON.parse( 333 | plaintext.substring(plaintext.indexOf(COOKIE_NAME_SEP) + 1) 334 | ), 335 | createdAt: createdAt, 336 | duration: duration 337 | }; 338 | } catch (ignored) { 339 | } 340 | 341 | cleanup(); 342 | return result; 343 | } 344 | 345 | /* 346 | * Session object 347 | * 348 | * this should be implemented with proxies at some point 349 | */ 350 | function Session(req, res, cookies, opts) { 351 | this.req = req; 352 | this.res = res; 353 | this.cookies = cookies; 354 | this.opts = opts; 355 | if (opts.cookie.ephemeral && opts.cookie.maxAge) { 356 | throw new Error("you cannot have an ephemeral cookie with a maxAge."); 357 | } 358 | 359 | this.content = {}; 360 | this.json = JSON.stringify(this._content); 361 | this.loaded = false; 362 | this.dirty = false; 363 | 364 | // no need to initialize it, loadFromCookie will do 365 | // via reset() or unbox() 366 | this.createdAt = null; 367 | this.duration = opts.duration; 368 | this.activeDuration = opts.activeDuration; 369 | 370 | // support for maxAge 371 | if (opts.cookie.maxAge) { 372 | this.expires = new Date(new Date().getTime() + opts.cookie.maxAge); 373 | } else { 374 | this.updateDefaultExpires(); 375 | } 376 | 377 | // here, we check that the security bits are set correctly 378 | var secure = (res.socket && res.socket.encrypted) || 379 | (req.connection && req.connection.proxySecure); 380 | if (opts.cookie.secure && !secure) { 381 | throw new Error("you cannot have a secure cookie unless the socket is " + 382 | " secure or you declare req.connection.proxySecure to be true."); 383 | } 384 | } 385 | 386 | Session.prototype = { 387 | updateDefaultExpires: function() { 388 | if (this.opts.cookie.maxAge) { 389 | return; 390 | } 391 | 392 | if (this.opts.cookie.ephemeral) { 393 | this.expires = null; 394 | } else { 395 | var time = this.createdAt || new Date().getTime(); 396 | // the cookie should expire when it becomes invalid 397 | // we add an extra second because the conversion to a date 398 | // truncates the milliseconds 399 | this.expires = new Date(time + this.duration + 1000); 400 | } 401 | }, 402 | 403 | clearContent: function(keysToPreserve) { 404 | var self = this; 405 | Object.keys(this._content).forEach(function(k) { 406 | // exclude this key if it's meant to be preserved 407 | if (keysToPreserve && (keysToPreserve.indexOf(k) > -1)) { 408 | return; 409 | } 410 | 411 | delete self._content[k]; 412 | }); 413 | }, 414 | 415 | reset: function(keysToPreserve) { 416 | this.clearContent(keysToPreserve); 417 | this.createdAt = new Date().getTime(); 418 | this.duration = this.opts.duration; 419 | this.updateDefaultExpires(); 420 | this.dirty = true; 421 | this.loaded = true; 422 | }, 423 | 424 | // alias for `reset` function for compatibility 425 | destroy: function(){ 426 | this.reset(); 427 | }, 428 | 429 | setDuration: function(newDuration, ephemeral) { 430 | if (ephemeral && this.opts.cookie.maxAge) { 431 | throw new Error("you cannot have an ephemeral cookie with a maxAge."); 432 | } 433 | if (!this.loaded) { 434 | this.loadFromCookie(true); 435 | } 436 | this.dirty = true; 437 | this.duration = newDuration; 438 | this.createdAt = new Date().getTime(); 439 | this.opts.cookie.ephemeral = ephemeral; 440 | this.updateDefaultExpires(); 441 | }, 442 | 443 | // take the content and do the encrypt-and-sign 444 | // boxing builds in the concept of createdAt 445 | box: function() { 446 | return encode(this.opts, this._content, this.duration, this.createdAt); 447 | }, 448 | 449 | unbox: function(content) { 450 | this.clearContent(); 451 | 452 | var unboxed = decode(this.opts, content); 453 | if (!unboxed) { 454 | return; 455 | } 456 | 457 | var self = this; 458 | 459 | 460 | Object.keys(unboxed.content).forEach(function(k) { 461 | self._content[k] = unboxed.content[k]; 462 | }); 463 | 464 | this.createdAt = unboxed.createdAt; 465 | this.duration = unboxed.duration; 466 | this.updateDefaultExpires(); 467 | }, 468 | 469 | updateCookie: function() { 470 | if (this.isDirty()) { 471 | // support for adding/removing cookie expires 472 | this.opts.cookie.expires = this.expires; 473 | 474 | try { 475 | this.cookies.set(this.opts.cookieName, this.box(), this.opts.cookie); 476 | } catch (x) { 477 | // this really shouldn't happen. Right now it happens if secure is set 478 | // but cookies can't determine that the connection is secure. 479 | } 480 | } 481 | }, 482 | 483 | loadFromCookie: function(forceReset) { 484 | var cookie = this.cookies.get(this.opts.cookieName); 485 | if (cookie) { 486 | this.unbox(cookie); 487 | 488 | var expiresAt = this.createdAt + this.duration; 489 | var now = Date.now(); 490 | // should we reset this session? 491 | if (expiresAt < now) { 492 | this.reset(); 493 | // if expiration is soon, push back a few minutes to not interrupt user 494 | } else if (expiresAt - now < this.activeDuration) { 495 | this.createdAt += this.activeDuration; 496 | this.dirty = true; 497 | this.updateDefaultExpires(); 498 | } 499 | } else { 500 | if (forceReset) { 501 | this.reset(); 502 | } else { 503 | return false; // didn't actually load the cookie 504 | } 505 | } 506 | 507 | this.loaded = true; 508 | this.json = JSON.stringify(this._content); 509 | return true; 510 | }, 511 | 512 | isDirty: function() { 513 | return this.dirty || (this.json !== JSON.stringify(this._content)); 514 | } 515 | 516 | }; 517 | 518 | Object.defineProperty(Session.prototype, 'content', { 519 | get: function getContent() { 520 | if (!this.loaded) { 521 | this.loadFromCookie(); 522 | } 523 | return this._content; 524 | }, 525 | set: function setContent(value) { 526 | Object.defineProperty(value, 'reset', { 527 | enumerable: false, 528 | value: this.reset.bind(this) 529 | }); 530 | Object.defineProperty(value, 'destroy', { 531 | enumerable: false, 532 | value: this.destroy.bind(this) 533 | }); 534 | Object.defineProperty(value, 'setDuration', { 535 | enumerable: false, 536 | value: this.setDuration.bind(this) 537 | }); 538 | this._content = value; 539 | } 540 | }); 541 | 542 | function clientSessionFactory(opts) { 543 | if (!opts) { 544 | throw new Error("no options provided, some are required"); 545 | } 546 | 547 | if (!(opts.secret || (opts.encryptionKey && opts.signatureKey))) { 548 | throw new Error("cannot set up sessions without a secret "+ 549 | "or encryptionKey/signatureKey pair"); 550 | } 551 | 552 | // defaults 553 | opts.cookieName = opts.cookieName || "session_state"; 554 | opts.duration = opts.duration || 24*60*60*1000; 555 | opts.activeDuration = 'activeDuration' in opts ? 556 | opts.activeDuration : ACTIVE_DURATION; 557 | 558 | var encAlg = opts.encryptionAlgorithm || DEFAULT_ENCRYPTION_ALGO; 559 | encAlg = encAlg.toLowerCase(); 560 | if (!ENCRYPTION_ALGORITHMS[encAlg]) { 561 | throw new Error('invalid encryptionAlgorithm, supported are: '+ 562 | Object.keys(ENCRYPTION_ALGORITHMS).join(', ')); 563 | } 564 | opts.encryptionAlgorithm = encAlg; 565 | 566 | var sigAlg = opts.signatureAlgorithm || DEFAULT_SIGNATURE_ALGO; 567 | sigAlg = sigAlg.toLowerCase(); 568 | if (!SIGNATURE_ALGORITHMS[sigAlg]) { 569 | throw new Error('invalid signatureAlgorithm, supported are: '+ 570 | Object.keys(SIGNATURE_ALGORITHMS).join(', ')); 571 | } 572 | opts.signatureAlgorithm = sigAlg; 573 | 574 | // set up cookie defaults 575 | opts.cookie = opts.cookie || {}; 576 | if (typeof opts.cookie.httpOnly === 'undefined') { 577 | opts.cookie.httpOnly = true; 578 | } 579 | 580 | // let's not default to secure just yet, 581 | // as this depends on the socket being secure, 582 | // which is tricky to determine if proxied. 583 | /* 584 | if (typeof(opts.cookie.secure) == 'undefined') 585 | opts.cookie.secure = true; 586 | */ 587 | 588 | setupKeys(opts); 589 | keyConstraints(opts); 590 | 591 | const propertyName = opts.requestKey || opts.cookieName; 592 | 593 | return function clientSession(req, res, next) { 594 | if (propertyName in req) { 595 | return next(); //self aware 596 | } 597 | 598 | var cookies = new Cookies(req, res); 599 | var rawSession; 600 | try { 601 | rawSession = new Session(req, res, cookies, opts); 602 | } catch (x) { 603 | // this happens only if there's a big problem 604 | process.nextTick(function() { 605 | next(new Error("client-sessions error: " + x.toString())); 606 | }); 607 | return; 608 | } 609 | 610 | Object.defineProperty(req, propertyName, { 611 | get: function getSession() { 612 | return rawSession.content; 613 | }, 614 | set: function setSession(value) { 615 | if (isObject(value)) { 616 | rawSession.content = value; 617 | } else { 618 | throw new TypeError("cannot set client-session to non-object"); 619 | } 620 | } 621 | }); 622 | 623 | 624 | var writeHead = res.writeHead; 625 | res.writeHead = function () { 626 | rawSession.updateCookie(); 627 | return writeHead.apply(res, arguments); 628 | }; 629 | 630 | next(); 631 | }; 632 | } 633 | 634 | module.exports = clientSessionFactory; 635 | 636 | 637 | // Expose encode and decode method 638 | 639 | module.exports.util = { 640 | encode: encode, 641 | decode: decode, 642 | computeHmac: computeHmac 643 | }; 644 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name" : "client-sessions", 3 | "version" : "0.8.0", 4 | "description" : "secure sessions stored in cookies", 5 | "main" : "lib/client-sessions", 6 | "repository" : { 7 | "type" : "git", 8 | "url" : "https://github.com/mozilla/node-client-sessions" 9 | }, 10 | "dependencies" : { 11 | "cookies" : "^0.7.0" 12 | }, 13 | "devDependencies": { 14 | "vows": "0.8.1", 15 | "express": "4.15.2", 16 | "request": "2.81.0" 17 | }, 18 | "author" : { 19 | "name" : "Ben Adida", 20 | "email" : "ben@adida.net" 21 | }, 22 | "scripts" : { 23 | "test": "vows --spec" 24 | }, 25 | "engines": { 26 | "node": ">= 0.8.0" 27 | }, 28 | "license": "MPL-2.0", 29 | "bugs": "https://github.com/mozilla/node-client-sessions/issues" 30 | } 31 | -------------------------------------------------------------------------------- /test/all-test.js: -------------------------------------------------------------------------------- 1 | // a NODE_ENV of test will supress console output to stderr which 2 | // connect likes to do when next() is called with a non-falsey error 3 | // message. We test such codepaths here. 4 | process.env.NODE_ENV = 'test'; 5 | 6 | var vows = require("vows"), 7 | assert = require("assert"), 8 | cookieSessions = require("../lib/client-sessions"), 9 | express = require("express"), 10 | request = require("request"); 11 | 12 | // screw you Vows 13 | process.on('uncaughtException', function(err) { 14 | console.error('Uncaught:', err.stack); 15 | process.exit(1); 16 | }); 17 | 18 | function create_app() { 19 | // set up the session middleware 20 | // XXX: same secret is important for a test 21 | var middleware = cookieSessions({ 22 | cookieName: 'session', 23 | secret: 'yo', 24 | activeDuration: 0, 25 | cookie: { 26 | maxAge: 5000 27 | } 28 | }); 29 | 30 | var app = express(); 31 | app.use(middleware); 32 | 33 | // set up a second cookie storage middleware 34 | var secureStoreMiddleware = cookieSessions({ 35 | cookieName: 'securestore', 36 | secret: 'yo', 37 | activeDuration: 0, 38 | cookie: { 39 | maxAge: 5000 40 | } 41 | }); 42 | 43 | app.use(secureStoreMiddleware); 44 | 45 | return app; 46 | } 47 | 48 | var startingPort = 9000; 49 | function createBrowser(server) { 50 | var jar = request.jar(); 51 | var browser = { 52 | get: function(url, options, callback) { 53 | if (typeof options === 'function') { 54 | callback = options; 55 | options = {}; 56 | } 57 | if (typeof callback !== 'function') { 58 | throw new TypeError('callback must be a function'); 59 | } 60 | // Ensure that server is ready to take connections 61 | if (server && !server.__listening) { 62 | (server.__deferred = server.__deferred || []).push([url, options, callback]); 63 | if (!server.__started) { 64 | server.__listener = server.listen(server.__port = ++startingPort, '127.0.0.1', function(){ 65 | process.nextTick(function(){ 66 | server.__deferred.forEach(function(args){ 67 | browser.get.apply(browser, args); 68 | }); 69 | }); 70 | server.__listening = true; 71 | }); 72 | server.__started = true; 73 | } 74 | return; 75 | } 76 | url = 'http://127.0.0.1:' + server.__port + url; 77 | options.uri = url; 78 | options.jar = jar; 79 | request.get(options, function(err, res, body) { 80 | if (err) console.error('ERR', err.stack); 81 | browser.cookies = jar.getCookies(url); 82 | callback(res, body); 83 | }); 84 | 85 | 86 | }, 87 | done: function() { 88 | server.__listener.close(); 89 | server.__listener = null; 90 | server.__listening = false; 91 | server.__started = false; 92 | } 93 | }; 94 | return browser; 95 | } 96 | 97 | var suite = vows.describe('client-sessions'); 98 | 99 | suite.addBatch({ 100 | "middleware" : { 101 | topic: function() { 102 | var self = this; 103 | var middleware = cookieSessions({ 104 | cookieName: 'session', 105 | secret: 'yo', 106 | activeDuration: 0, 107 | cookie: { 108 | maxAge: 5000 109 | } 110 | }); 111 | 112 | var req = { 113 | headers: {} 114 | }; 115 | var res = {}; 116 | 117 | middleware(req, res, function(err) { 118 | self.callback(err, req, res); 119 | }); 120 | }, 121 | "includes a session object": function(err, req) { 122 | assert.isObject(req.session); 123 | }, 124 | "session object stores and retrieves values properly": function(err, req) { 125 | req.session.foo = 'bar'; 126 | assert.equal(req.session.foo, 'bar'); 127 | }, 128 | "session object has reset function": function(err, req) { 129 | assert.isFunction(req.session.reset); 130 | }, 131 | "session object has setDuration function": function(err, req) { 132 | assert.isFunction(req.session.setDuration); 133 | }, 134 | "set variables and clear them yields no variables": function(err, req) { 135 | req.session.bar = 'baz'; 136 | req.session.reset(); 137 | assert.isUndefined(req.session.bar); 138 | }, 139 | "set variables does the right thing for Object.keys": function(err, req) { 140 | req.session.reset(); 141 | req.session.foo = 'foobar'; 142 | assert.equal(Object.keys(req.session).length, 1); 143 | assert.equal(Object.keys(req.session)[0], 'foo'); 144 | }, 145 | "reset preserves variables when asked": function(err, req) { 146 | req.session.reset(); 147 | req.session.foo = 'foobar'; 148 | req.session.bar = 'foobar2'; 149 | 150 | req.session.reset(['foo']); 151 | 152 | assert.isUndefined(req.session.bar); 153 | assert.equal(req.session.foo, 'foobar'); 154 | }, 155 | "set session property absorbs set object": function(err, req) { 156 | req.session.reset(); 157 | req.session.foo = 'quux'; 158 | 159 | req.session = { bar: 'baz' }; 160 | 161 | assert.isUndefined(req.session.foo); 162 | assert.isFunction(req.session.reset); 163 | assert.isFunction(req.session.setDuration); 164 | assert.equal(req.session.bar, 'baz'); 165 | 166 | assert.throws(function() { 167 | req.session = 'blah'; 168 | }, TypeError); 169 | } 170 | } 171 | }); 172 | 173 | suite.addBatch({ 174 | "a single request object" : { 175 | topic: function() { 176 | var self = this; 177 | 178 | // simple app 179 | var app = create_app(); 180 | 181 | app.get("/foo", function(req, res) { 182 | req.session.foo = 'foobar'; 183 | res.send("hello"); 184 | }); 185 | 186 | var browser = createBrowser(app); 187 | browser.get("/foo", function(res, $) { 188 | browser.done(); 189 | self.callback(null, res); 190 | }); 191 | }, 192 | "includes a set-cookie header": function(err, res) { 193 | assert.isArray(res.headers['set-cookie']); 194 | }, 195 | "only one set-cookie header": function(err, res) { 196 | assert.equal(res.headers['set-cookie'].length, 1); 197 | }, 198 | "with an expires attribute": function(err, res) { 199 | assert.match(res.headers['set-cookie'][0], /expires/); 200 | }, 201 | "with a path attribute": function(err, res) { 202 | assert.match(res.headers['set-cookie'][0], /path/); 203 | }, 204 | "with an httpOnly attribute": function(err, res) { 205 | assert.match(res.headers['set-cookie'][0], /httponly/); 206 | } 207 | } 208 | }); 209 | 210 | suite.addBatch({ 211 | "across two requests" : { 212 | topic: function() { 213 | var self = this; 214 | 215 | // simple app 216 | var app = create_app(); 217 | 218 | app.get("/foo", function(req, res) { 219 | req.session.reset(); 220 | req.session.foo = 'foobar'; 221 | req.session.bar = [1, 2, 3]; 222 | res.send("foo"); 223 | }); 224 | 225 | app.get("/bar", function(req, res) { 226 | self.callback(null, req); 227 | res.send("bar"); 228 | }); 229 | 230 | var browser = createBrowser(app); 231 | browser.get("/foo", function(res, $) { 232 | browser.get("/bar", function(res, $) { 233 | browser.done(); 234 | }); 235 | }); 236 | }, 237 | "session maintains state": function(err, req) { 238 | assert.equal(req.session.foo, 'foobar'); 239 | assert.equal(req.session.bar.length, 3); 240 | assert.equal(req.session.bar[0], 1); 241 | assert.equal(req.session.bar[1], 2); 242 | assert.equal(req.session.bar[2], 3); 243 | } 244 | } 245 | }); 246 | 247 | suite.addBatch({ 248 | "across two requests" : { 249 | topic: function() { 250 | var self = this; 251 | 252 | // simple app 253 | var app = create_app(); 254 | 255 | app.get("/foo", function(req, res) { 256 | req.session.reset(); 257 | req.session.foo = 'foobar'; 258 | res.send("foo"); 259 | }); 260 | 261 | app.get("/bar", function(req, res) { 262 | self.callback(null, req); 263 | res.send("bar"); 264 | }); 265 | 266 | var browser = createBrowser(app); 267 | browser.get("/foo", function(res, $) { 268 | browser.get("/bar", function(res, $) { 269 | browser.done(); 270 | }); 271 | }); 272 | }, 273 | "resetting a session with an existing cookie value yields no variables": function(err, req) { 274 | req.session.reset(); 275 | assert.isUndefined(req.session.foo); 276 | } 277 | } 278 | }); 279 | 280 | suite.addBatch({ 281 | "across three requests" : { 282 | topic: function() { 283 | var self = this; 284 | 285 | // simple app 286 | var app = create_app(); 287 | 288 | app.get("/foo", function(req, res) { 289 | req.session.reset(); 290 | req.session.foo = 'foobar'; 291 | req.session.bar = 'foobar2'; 292 | res.send("foo"); 293 | }); 294 | 295 | app.get("/bar", function(req, res) { 296 | delete req.session.bar; 297 | res.send("bar"); 298 | }); 299 | 300 | app.get("/baz", function(req, res) { 301 | self.callback(null, req); 302 | res.send("baz"); 303 | }); 304 | 305 | var browser = createBrowser(app); 306 | browser.get("/foo", function(res, $) { 307 | browser.get("/bar", function(res, $) { 308 | browser.get("/baz", function(res, $) { 309 | browser.done(); 310 | }); 311 | }); 312 | }); 313 | }, 314 | "session maintains state": function(err, req) { 315 | assert.equal(req.session.foo, 'foobar'); 316 | assert.isUndefined(req.session.bar); 317 | } 318 | }, 319 | "across three requests with deep objects" : { 320 | topic: function() { 321 | var self = this; 322 | 323 | // simple app 324 | var app = create_app(); 325 | 326 | app.get("/foo", function(req, res) { 327 | req.session.reset(); 328 | req.session.foo = 'foobar'; 329 | req.session.bar = { a: 'b' }; 330 | res.send("foo"); 331 | }); 332 | 333 | app.get("/bar", function(req, res) { 334 | req.session.bar.c = 'd'; 335 | res.send("bar"); 336 | }); 337 | 338 | app.get("/baz", function(req, res) { 339 | self.callback(null, req); 340 | res.send("baz"); 341 | }); 342 | 343 | var browser = createBrowser(app); 344 | browser.get("/foo", function(res, $) { 345 | browser.get("/bar", function(res, $) { 346 | browser.get("/baz", function(res, $) { 347 | browser.done(); 348 | }); 349 | }); 350 | }); 351 | }, 352 | "session maintains state": function(err, req) { 353 | assert.equal(req.session.foo, 'foobar'); 354 | assert.equal(req.session.bar.c, 'd'); 355 | } 356 | } 357 | }); 358 | 359 | suite.addBatch({ 360 | "reading from an existing session" : { 361 | topic: function() { 362 | var self = this; 363 | 364 | // simple app 365 | var app = create_app(); 366 | 367 | app.get("/foo", function(req, res) { 368 | req.session.foo = 'foobar'; 369 | res.send("foo"); 370 | }); 371 | 372 | app.get("/bar", function(req, res) { 373 | res.send(req.session.foo); 374 | }); 375 | 376 | var browser = createBrowser(app); 377 | browser.get("/foo", function(res, $) { 378 | browser.get("/bar", function(res, $) { 379 | browser.done(); 380 | // observe the response to the second request 381 | self.callback(null, res); 382 | }); 383 | }); 384 | }, 385 | "does not set a cookie": function(err, res) { 386 | assert.isUndefined(res.headers['set-cookie']); 387 | } 388 | }, 389 | "reading from a non-existing session" : { 390 | topic: function() { 391 | var self = this; 392 | 393 | // simple app 394 | var app = create_app(); 395 | 396 | app.get("/foo", function(req, res) { 397 | // this should send undefined, not null 398 | res.send(req.session.foo); 399 | }); 400 | 401 | var browser = createBrowser(app); 402 | browser.get("/foo", function(res, $) { 403 | browser.done(); 404 | self.callback(null, res, $); 405 | }); 406 | }, 407 | "does not set a cookie": function(err, res, body) { 408 | assert.isUndefined(res.headers['set-cookie']); 409 | assert.equal(body, ''); // undefined becomes an empty string 410 | } 411 | } 412 | }); 413 | 414 | suite.addBatch({ 415 | "writing to a session" : { 416 | topic: function() { 417 | var self = this; 418 | 419 | // simple app 420 | var app = create_app(); 421 | 422 | app.get("/foo", function(req, res) { 423 | req.session.foo = 'foobar'; 424 | res.send("foo"); 425 | }); 426 | 427 | app.get("/bar", function(req, res) { 428 | req.session.reset(); 429 | req.session.reset(); 430 | req.session.bar = 'bar'; 431 | req.session.baz = 'baz'; 432 | res.send("bar"); 433 | }); 434 | 435 | var browser = createBrowser(app); 436 | browser.get("/foo", function(res, $) { 437 | browser.get("/bar", function(res, $) { 438 | browser.done(); 439 | // observe the response to the second request 440 | self.callback(null, res); 441 | }); 442 | }); 443 | }, 444 | "sets a cookie": function(err, res) { 445 | assert.isArray(res.headers['set-cookie']); 446 | }, 447 | "and only one cookie": function(err, res) { 448 | assert.equal(res.headers['set-cookie'].length, 1); 449 | } 450 | } 451 | }); 452 | 453 | function create_app_with_duration() { 454 | // simple app 455 | var app = express(); 456 | app.use(cookieSessions({ 457 | cookieName: 'session', 458 | secret: 'yo', 459 | activeDuration: 0, 460 | duration: 500 // 0.5 seconds 461 | })); 462 | 463 | app.get("/foo", function(req, res) { 464 | req.session.reset(); 465 | req.session.foo = 'foobar'; 466 | res.send("foo"); 467 | }); 468 | 469 | return app; 470 | } 471 | 472 | suite.addBatch({ 473 | "querying within duration" : { 474 | topic: function() { 475 | var self = this; 476 | 477 | var app = create_app_with_duration(); 478 | app.get("/bar", function(req, res) { 479 | self.callback(null, req); 480 | res.send("bar"); 481 | }); 482 | 483 | var browser = createBrowser(app); 484 | browser.get("/foo", function(res, $) { 485 | setTimeout(function () { 486 | browser.get("/bar", function(res, $) { 487 | browser.done(); 488 | }); 489 | }, 200); 490 | }); 491 | }, 492 | "session still has state": function(err, req) { 493 | assert.equal(req.session.foo, 'foobar'); 494 | } 495 | } 496 | }); 497 | 498 | suite.addBatch({ 499 | "modifying the session": { 500 | topic: function() { 501 | var self = this; 502 | 503 | var app = create_app_with_duration(); 504 | app.get("/bar", function(req, res) { 505 | self.callback(null, req); 506 | res.send("bar"); 507 | }); 508 | 509 | var browser = createBrowser(app); 510 | var firstCreatedAt, secondCreatedAt; 511 | browser.get("/foo", function(res, $) { 512 | browser.get("/bar", function(res, $) { 513 | browser.done(); 514 | }); 515 | }); 516 | }, 517 | "doesn't change createdAt": function(err, req) { 518 | assert.equal(req.session.foo, 'foobar'); 519 | } 520 | } 521 | }); 522 | 523 | suite.addBatch({ 524 | "querying outside the duration time": { 525 | topic: function() { 526 | var self = this; 527 | 528 | var app = create_app_with_duration(); 529 | app.get("/bar", function(req, res) { 530 | self.callback(null, req); 531 | res.send("bar"); 532 | }); 533 | 534 | var browser = createBrowser(app); 535 | browser.get("/foo", function(res, $) { 536 | setTimeout(function () { 537 | browser.get("/bar", function(res, $) { 538 | browser.done(); 539 | }); 540 | }, 800); 541 | }); 542 | }, 543 | "session no longer has state": function(err, req) { 544 | assert.isUndefined(req.session.foo); 545 | } 546 | } 547 | }); 548 | 549 | suite.addBatch({ 550 | "querying twice, each at 2/5 duration time": { 551 | topic: function() { 552 | var self = this; 553 | 554 | var app = create_app_with_duration(); 555 | app.get("/bar", function(req, res) { 556 | req.session.baz = Math.random(); 557 | res.send("bar"); 558 | }); 559 | 560 | app.get("/bar2", function(req, res) { 561 | self.callback(null, req); 562 | res.send("bar2"); 563 | }); 564 | 565 | var browser = createBrowser(app); 566 | // first query resets the session to full duration 567 | browser.get("/foo", function(res, $) { 568 | setTimeout(function () { 569 | // this query should NOT reset the session 570 | browser.get("/bar", function(res, $) { 571 | setTimeout(function () { 572 | // so the session should still be valid 573 | browser.get("/bar2", function(res, $) { 574 | browser.done(); 575 | }); 576 | }, 200); 577 | }); 578 | }, 200); 579 | }); 580 | }, 581 | "session still has state": function(err, req) { 582 | assert.isDefined(req.session.baz); 583 | } 584 | } 585 | }); 586 | 587 | suite.addBatch({ 588 | "querying twice, each at 3/5 duration time": { 589 | topic: function() { 590 | var self = this; 591 | 592 | var app = create_app_with_duration(); 593 | app.get("/bar", function(req, res) { 594 | req.session.baz = Math.random(); 595 | res.send("bar"); 596 | }); 597 | 598 | app.get("/bar2", function(req, res) { 599 | self.callback(null, req); 600 | res.send("bar2"); 601 | }); 602 | 603 | var browser = createBrowser(app); 604 | // first query resets the session to full duration 605 | browser.get("/foo", function(res, $) { 606 | setTimeout(function () { 607 | // this query should NOT reset the session 608 | browser.get("/bar", function(res, $) { 609 | setTimeout(function () { 610 | // so the session should be dead by now 611 | browser.get("/bar2", function(res, $) { 612 | browser.done(); 613 | }); 614 | }, 300); 615 | }); 616 | }, 300); 617 | }); 618 | }, 619 | "session no longer has state": function(err, req) { 620 | assert.isUndefined(req.session.baz); 621 | } 622 | } 623 | }); 624 | 625 | function create_app_with_duration_modification() { 626 | // simple app 627 | var app = express(); 628 | 629 | app.use(cookieSessions({ 630 | cookieName: 'session', 631 | secret: 'yobaby', 632 | activeDuration: 0, 633 | duration: 5000 // 5.0 seconds 634 | })); 635 | 636 | app.get("/create", function(req, res) { 637 | req.session.foo = "foo"; 638 | res.send("created"); 639 | }); 640 | 641 | app.get("/augment", function(req, res) { 642 | req.session.bar = "bar"; 643 | res.send("augmented"); 644 | }); 645 | 646 | // invoking this will change the session duration to 500ms 647 | app.get("/change", function(req, res) { 648 | req.session.setDuration(500); 649 | res.send("duration changed"); 650 | }); 651 | 652 | 653 | return app; 654 | } 655 | 656 | suite.addBatch({ 657 | "after changing cookie duration and querying outside the modified duration": { 658 | topic: function() { 659 | var self = this; 660 | 661 | var app = create_app_with_duration_modification(); 662 | app.get("/complete", function(req, res) { 663 | self.callback(null, req); 664 | res.send("bar"); 665 | }); 666 | 667 | var browser = createBrowser(app); 668 | browser.get("/create", function(res, $) { 669 | browser.get("/change", function(res, $) { 670 | setTimeout(function () { 671 | browser.get("/complete", function(res, $) { 672 | browser.done(); 673 | }); 674 | }, 700); 675 | }); 676 | }); 677 | }, 678 | "session no longer has state": function(err, req) { 679 | assert.isUndefined(req.session.foo); 680 | } 681 | } 682 | }); 683 | 684 | var initialCookie; 685 | var updatedCookie; 686 | 687 | suite.addBatch({ 688 | "changing duration": { 689 | topic: function() { 690 | var self = this; 691 | 692 | var app = create_app_with_duration_modification(); 693 | app.get("/complete", function(req, res) { 694 | self.callback(null, req); 695 | res.send("bar"); 696 | }); 697 | 698 | var browser = createBrowser(app); 699 | browser.get("/create", function(res, $) { 700 | initialCookie = browser.cookies[0].value; 701 | browser.get("/change", function(res, $) { 702 | updatedCookie = browser.cookies[0].value; 703 | browser.get("/complete", function(res, $) { 704 | browser.done(); 705 | }); 706 | }); 707 | }); 708 | }, 709 | "doesn't affect session variables": function(err, req) { 710 | assert.equal(req.session.foo, "foo"); 711 | }, 712 | "does update creation time": function(err, req) { 713 | assert.notEqual(initialCookie.split('.')[2], 714 | updatedCookie.split('.')[2], 715 | "after duration update, creation should be updated"); 716 | }, 717 | "does update duration": function(err, req) { 718 | assert.strictEqual(parseInt(initialCookie.split('.')[3], 10), 5000); 719 | assert.strictEqual(parseInt(updatedCookie.split('.')[3], 10), 500); 720 | } 721 | } 722 | }); 723 | 724 | suite.addBatch({ 725 | "after changing duration then setting a new session variable": { 726 | topic: function() { 727 | var self = this; 728 | 729 | var app = create_app_with_duration_modification(); 730 | app.get("/set_then_duration", function(req, res) { 731 | req.session.baz = "baz"; 732 | req.session.setDuration(500); 733 | res.send("did it"); 734 | }); 735 | 736 | 737 | app.get("/complete", function(req, res) { 738 | self.callback(null, req); 739 | res.send("bar"); 740 | }); 741 | 742 | var browser = createBrowser(app); 743 | browser.get("/create", function(res, $) { 744 | browser.get("/set_then_duration", function(res, $) { 745 | browser.get("/complete", function(res, $) { 746 | browser.done(); 747 | }); 748 | }); 749 | }); 750 | }, 751 | "variable is visible": function(err, req) { 752 | assert.equal(req.session.foo, "foo"); 753 | assert.equal(req.session.baz, "baz"); 754 | } 755 | } 756 | }); 757 | 758 | suite.addBatch({ 759 | "after setting a new session variable then changing duration": { 760 | topic: function() { 761 | var self = this; 762 | 763 | var app = create_app_with_duration_modification(); 764 | app.get("/set_then_duration", function(req, res) { 765 | req.session.setDuration(500); 766 | req.session.baz = "baz"; 767 | res.send("did it"); 768 | }); 769 | 770 | 771 | app.get("/complete", function(req, res) { 772 | self.callback(null, req); 773 | res.send("bar"); 774 | }); 775 | 776 | var browser = createBrowser(app); 777 | browser.get("/create", function(res, $) { 778 | browser.get("/set_then_duration", function(res, $) { 779 | browser.get("/complete", function(res, $) { 780 | browser.done(); 781 | }); 782 | }); 783 | }); 784 | }, 785 | "variable is visible": function(err, req) { 786 | assert.equal(req.session.foo, "foo"); 787 | assert.equal(req.session.baz, "baz"); 788 | } 789 | } 790 | }); 791 | 792 | suite.addBatch({ 793 | "setting new variables then invoking setDuration": { 794 | topic: function() { 795 | var self = this; 796 | 797 | var app = create_app_with_duration_modification(); 798 | app.get("/complete", function(req, res) { 799 | self.callback(null, req); 800 | res.send("bar"); 801 | }); 802 | 803 | var browser = createBrowser(app); 804 | browser.get("/create", function(res, $) { 805 | browser.get("/change", function(res, $) { 806 | browser.get("/augment", function(res, $) { 807 | browser.get("/complete", function(res, $) { 808 | browser.done(); 809 | }); 810 | }); 811 | }); 812 | }); 813 | }, 814 | "both variables are visible": function(err, req) { 815 | assert.equal(req.session.foo, "foo"); 816 | assert.equal(req.session.bar, "bar"); 817 | } 818 | } 819 | }); 820 | 821 | function create_app_with_secure(firstMiddleware) { 822 | // set up the session middleware 823 | var middleware = cookieSessions({ 824 | cookieName: 'session', 825 | secret: 'yo', 826 | activeDuration: 0, 827 | cookie: { 828 | maxAge: 5000, 829 | secure: true 830 | } 831 | }); 832 | 833 | var app = express(); 834 | if (firstMiddleware) 835 | app.use(firstMiddleware); 836 | 837 | app.use(middleware); 838 | 839 | return app; 840 | } 841 | 842 | suite.addBatch({ 843 | "across two requests, without proxySecure, secure cookies" : { 844 | topic: function() { 845 | var self = this; 846 | 847 | var app = create_app_with_secure(); 848 | 849 | app.get("/foo", function(req, res) { 850 | res.send("foo"); 851 | }); 852 | 853 | var browser = createBrowser(app); 854 | browser.get("/foo", function(res, $) { 855 | browser.done(); 856 | self.callback(null, res); 857 | }); 858 | }, 859 | "cannot be set": function(err, res) { 860 | assert.equal(res.statusCode, 500); 861 | } 862 | } 863 | }); 864 | 865 | suite.addBatch({ 866 | "across two requests, with proxySecure, secure cookies" : { 867 | topic: function() { 868 | var self = this; 869 | 870 | var app = create_app_with_secure(function(req, res, next) { 871 | // say it is proxySecure 872 | req.connection.proxySecure = true; 873 | next(); 874 | }); 875 | 876 | app.get("/foo", function(req, res) { 877 | res.send("foo"); 878 | }); 879 | 880 | var browser = createBrowser(app); 881 | browser.get("/foo", function(res, $) { 882 | browser.done(); 883 | self.callback(null, res); 884 | }); 885 | 886 | }, 887 | "can be set": function(err, res) { 888 | assert.equal(res.statusCode, 200); 889 | } 890 | } 891 | }); 892 | 893 | 894 | suite.addBatch({ 895 | "public encode and decode util methods" : { 896 | topic: function() { 897 | var self = this; 898 | 899 | var app = create_app(); 900 | app.get("/foo", function(req, res) { 901 | self.callback(null, req); 902 | res.send("hello"); 903 | }); 904 | 905 | var browser = createBrowser(app); 906 | browser.get("/foo", function(res, $) { 907 | browser.done(); 908 | }); 909 | }, 910 | "encode " : function(err, req){ 911 | var result = cookieSessions.util.encode({cookieName: 'session', secret: 'yo'}, {foo:'bar'}); 912 | var result_arr = result.split("."); 913 | assert.equal(result_arr.length, 5); 914 | }, 915 | "encode and decode - is object" : function(err, req){ 916 | var encoded = cookieSessions.util.encode({cookieName: 'session', secret: 'yo'}, {foo:'bar'}); 917 | var decoded = cookieSessions.util.decode({cookieName: 'session', secret: 'yo'}, encoded); 918 | assert.isObject(decoded); 919 | }, 920 | "encode and decode - has all values" : function(err, req){ 921 | var encoded = cookieSessions.util.encode({cookieName: 'session', secret: 'yo'}, {foo:'bar', bar:'foo'}); 922 | var decoded = cookieSessions.util.decode({cookieName: 'session', secret: 'yo'}, encoded); 923 | assert.equal(decoded.content.foo, 'bar'); 924 | assert.equal(decoded.content.bar, 'foo'); 925 | assert.isNumber(decoded.duration); 926 | assert.isNumber(decoded.createdAt); 927 | }, 928 | "encode and decode - override duration and createdAt" : function(err, req){ 929 | var encoded = cookieSessions.util.encode({cookieName: 'session', secret: 'yo'}, {foo:'bar', bar:'foo'}, 5000, 1355408039221); 930 | var decoded = cookieSessions.util.decode({cookieName: 'session', secret: 'yo'}, encoded); 931 | assert.equal(decoded.duration, 5000); 932 | assert.equal(decoded.createdAt, 1355408039221); 933 | }, 934 | "encode and decode - default duration" : function(err, req){ 935 | var encoded = cookieSessions.util.encode({cookieName: 'session', secret: 'yo'}, {foo:'bar'}); 936 | var decoded = cookieSessions.util.decode({cookieName: 'session', secret: 'yo'}, encoded); 937 | assert.equal(decoded.duration, 86400000); 938 | }, 939 | "encode and decode - tampered HMAC" : function(err, req){ 940 | var encodedReal = 'LVB3G2lnPF75RzsT9mz7jQ.RT1Lcq0dOJ_DMRHyWJ4NZPjBXr2WzkFcUC4NO78gbCQ.1371704898483.5000.ILEusgnajT1sqCWLuzaUt-HFn2KPjYNd38DhI7aRCb9'; 941 | var encodedFake = encodedReal.substring(0, encodedReal.length - 1) + 'A'; 942 | 943 | var decodedReal = cookieSessions.util.decode({cookieName: 'session', secret: 'yo'}, encodedReal); 944 | assert.isObject(decodedReal); 945 | var decodedFake = cookieSessions.util.decode({cookieName: 'session', secret: 'yo'}, encodedFake); 946 | assert.isUndefined(decodedFake); 947 | }, 948 | "decode - invalid input" : function(err, req){ 949 | var notEnoughComponents = 'LVB3G2lnPF75RzsT9mz7jQ.RT1Lcq0dOJ_DMRHyWJ4NZPjBXr2WzkFcUC4NO78gbCQ.1371704898483.5000'; 950 | assert.isUndefined(cookieSessions.util.decode({cookieName: 'session', secret: 'yo'}, notEnoughComponents)); 951 | 952 | var invalidBase64 = 'LVB3G2lnPF75RzsT9mz7jQ.RT1Lcq0dOJ_DMRHyWJ4NZPjBXr2WzkFcUC4NO78gb.1371704898483.5000.ILEusgnajT1sqCWLuzaUt-HFn2KPjYNd38DhI7aRCb9'; 953 | assert.isUndefined(cookieSessions.util.decode({cookieName: 'session', secret: 'yo'}, invalidBase64)); 954 | } 955 | } 956 | }); 957 | 958 | suite.addBatch({ 959 | "two middlewares": { 960 | topic: function() { 961 | var self = this; 962 | 963 | var app = create_app(); 964 | app.get("/foo", function(req, res) { 965 | self.callback(null, req); 966 | res.send("hello"); 967 | }); 968 | 969 | var browser = createBrowser(app); 970 | browser.get("/foo", function(res, $){ 971 | browser.done(); 972 | }); 973 | }, 974 | "We can write to both stores": function(err, req) { 975 | req.session.foo = 'bar'; 976 | req.securestore.foo = 'buzz'; 977 | req.securestore.widget = 4; 978 | 979 | assert.equal(req.session.foo, 'bar'); 980 | assert.equal(req.securestore.foo, 'buzz'); 981 | assert.equal(req.securestore.widget, 4); 982 | } 983 | } 984 | }); 985 | 986 | suite.addBatch({ 987 | "specifying requestKey different than cookieName": { 988 | topic: function() { 989 | var self = this; 990 | 991 | var app = express(); 992 | app.use(cookieSessions({ 993 | cookieName: 'ooga_booga_momma', 994 | activeDuration: 0, 995 | requestKey: 'ses', 996 | secret: 'yo' 997 | })); 998 | 999 | app.get('/foo', function(req, res) { 1000 | self.callback(null, req); 1001 | res.send('hello'); 1002 | }); 1003 | 1004 | var browser = createBrowser(app); 1005 | browser.get("/foo", function(res, $){ 1006 | browser.done(); 1007 | }); 1008 | }, 1009 | "session is defined as req[requestKey]": function(err, req) { 1010 | assert.isObject(req.ses); 1011 | assert.strictEqual(Object.keys(req.ses).length, 0); 1012 | assert.isUndefined(req.session); 1013 | assert.isUndefined(req.ooga_booga_momma); 1014 | } 1015 | } 1016 | }); 1017 | 1018 | suite.addBatch({ 1019 | "swapping two cookies": { 1020 | topic: function() { 1021 | var self = this; 1022 | var app = create_app(); //important that they use the same secret 1023 | app.get('/foo', function(req, res) { 1024 | req.session.foo = 'bar'; 1025 | req.securestore.foo = 'buzz'; 1026 | req.securestore.widget = 4; 1027 | res.send('hello'); 1028 | }); 1029 | app.get('/bar', function(req, res) { 1030 | self.callback(null, req); 1031 | res.send('bye'); 1032 | }); 1033 | 1034 | createBrowser(app).get('/foo', function(res, $){ 1035 | var cookies = res.headers['set-cookie']; 1036 | var firstCookie = cookies[0]; 1037 | var secondCookie = cookies[1]; 1038 | 1039 | function getCookieName(cookieHeader) { 1040 | return cookieHeader.substring(0, cookieHeader.indexOf('=')); 1041 | } 1042 | 1043 | function getCookieValue(cookieHeader) { 1044 | return cookieHeader.substring(cookieHeader.indexOf('='), cookieHeader.indexOf(';')); 1045 | } 1046 | 1047 | var firstHijack = getCookieName(firstCookie) + getCookieValue(secondCookie); 1048 | var secondHijack = getCookieName(secondCookie) + getCookieValue(firstCookie); 1049 | 1050 | var browser = createBrowser(app); 1051 | browser.get('/bar', { 1052 | headers: { 'Cookie': firstHijack + '; ' + secondHijack } 1053 | }, function(res, $){ 1054 | browser.done(); 1055 | }); 1056 | 1057 | }); 1058 | }, 1059 | "doesn't keep using cookie": function(err, req) { 1060 | // session.foo should not be what securestore.foo was, or else 1061 | // we swapped cookies! 1062 | assert.notEqual(req.session.foo, 'buzz'); 1063 | assert.notEqual(req.session.widget, 4); 1064 | assert.notEqual(req.securestore.foo, 'bar'); 1065 | } 1066 | } 1067 | }); 1068 | 1069 | suite.addBatch({ 1070 | "missing cookie maxAge": { 1071 | topic: function() { 1072 | var self = this; 1073 | 1074 | var app = express(); 1075 | app.use(cookieSessions({ 1076 | cookieName: 'session', 1077 | duration: 50000, 1078 | activeDuration: 0, 1079 | secret: 'yo' 1080 | })); 1081 | 1082 | app.get("/foo", function(req, res) { 1083 | req.session.foo = 'foobar'; 1084 | res.send("hello"); 1085 | }); 1086 | 1087 | var browser = createBrowser(app); 1088 | browser.get("/foo", function(res, $) { 1089 | browser.done(); 1090 | self.callback(null, res); 1091 | }); 1092 | }, 1093 | "still has an expires attribute": function(err, res) { 1094 | assert.match(res.headers['set-cookie'][0], /expires/, "cookie is a session cookie"); 1095 | }, 1096 | "which roughly matches the session duration": function(err, res) { 1097 | var expiryValue = res.headers['set-cookie'][0].replace(/^.*expires=([^;]+);.*$/, "$1"); 1098 | var expiryDate = new Date(expiryValue); 1099 | var cookieDuration = expiryDate.getTime() - Date.now(); 1100 | assert(Math.abs(50000 - cookieDuration) < 1500, "expiry is pretty far from the specified duration"); 1101 | } 1102 | }, 1103 | "changing the duration": { 1104 | topic: function() { 1105 | var self = this; 1106 | 1107 | var app = express(); 1108 | app.use(cookieSessions({ 1109 | cookieName: 'session', 1110 | duration: 500, 1111 | activeDuration: 0, 1112 | secret: 'yo' 1113 | })); 1114 | 1115 | app.get("/foo", function(req, res) { 1116 | req.session.foo = 'foobar'; 1117 | res.send("hello"); 1118 | }); 1119 | 1120 | app.get("/bar", function(req, res) { 1121 | req.session.setDuration(5000); 1122 | res.send("bar"); 1123 | }); 1124 | 1125 | var browser = createBrowser(app); 1126 | browser.get("/foo", function(res, $) { 1127 | setTimeout(function () { 1128 | browser.get("/bar", function(res, $) { 1129 | browser.done(); 1130 | self.callback(null, res); 1131 | }); 1132 | }, 200); 1133 | }); 1134 | }, 1135 | "updates the cookie expiry": function(err, res) { 1136 | var expiryValue = res.headers['set-cookie'][0].replace(/^.*expires=([^;]+);.*$/, "$1"); 1137 | var expiryDate = new Date(expiryValue); 1138 | var cookieDuration = expiryDate.getTime() - Date.now(); 1139 | assert(Math.abs(cookieDuration - 5000) < 1000, "expiry is pretty far from the specified duration"); 1140 | } 1141 | }, 1142 | "active user with session close to expiration": { 1143 | topic: function() { 1144 | var app = express(); 1145 | var self = this; 1146 | app.use(cookieSessions({ 1147 | cookieName: 'session', 1148 | duration: 300, 1149 | activeDuration: 500, 1150 | secret: 'yo' 1151 | })); 1152 | 1153 | app.get("/foo", function(req, res) { 1154 | req.session.foo = 'foobar'; 1155 | res.send("hello"); 1156 | }); 1157 | 1158 | app.get("/bar", function(req, res) { 1159 | req.session.bar = 'baz'; 1160 | res.send('hi'); 1161 | }); 1162 | 1163 | app.get("/baz", function(req, res) { 1164 | res.json({ "msg": req.session.foo + req.session.bar }); 1165 | }); 1166 | 1167 | var browser = createBrowser(app); 1168 | browser.get("/foo", function() { 1169 | browser.get("/bar", function() { 1170 | setTimeout(function () { 1171 | browser.get("/baz", {json: true}, function(res, first) { 1172 | setTimeout(function() { 1173 | browser.get('/baz', {json: true}, function(res, second) { 1174 | browser.done(); 1175 | self.callback(null, first, second); 1176 | }); 1177 | }, 1000); 1178 | }); 1179 | }, 400); 1180 | }); 1181 | }); 1182 | 1183 | }, 1184 | "extends session duration": function(err, extended, tooLate) { 1185 | assert.equal(extended.msg, 'foobarbaz'); 1186 | assert.equal(tooLate.msg, null); 1187 | } 1188 | } 1189 | }); 1190 | 1191 | var shared_browser1; 1192 | var shared_browser2; 1193 | 1194 | suite.addBatch({ 1195 | "non-ephemeral cookie": { 1196 | topic: function() { 1197 | var self = this; 1198 | 1199 | var app = express(); 1200 | app.use(cookieSessions({ 1201 | cookieName: 'session', 1202 | duration: 5000, 1203 | secret: 'yo', 1204 | cookie: { 1205 | ephemeral: false 1206 | } 1207 | })); 1208 | 1209 | app.get("/foo", function(req, res) { 1210 | req.session.foo = 'foobar'; 1211 | res.send("hello"); 1212 | }); 1213 | 1214 | app.get("/bar", function(req, res) { 1215 | req.session.setDuration(6000, true); 1216 | res.send("hello"); 1217 | }); 1218 | 1219 | shared_browser1 = createBrowser(app); 1220 | shared_browser1.get("/foo", function(res, $) { 1221 | self.callback(null, res); 1222 | }); 1223 | }, 1224 | "has an expires attribute": function(err, res) { 1225 | assert.match(res.headers['set-cookie'][0], /expires/, "cookie is a session cookie"); 1226 | }, 1227 | "changing to an ephemeral one": { 1228 | topic: function() { 1229 | var self = this; 1230 | shared_browser1.get("/bar", function(res, $) { 1231 | shared_browser1.done(); 1232 | self.callback(null, res); 1233 | }); 1234 | }, 1235 | "removes its expires attribute": function(err, res) { 1236 | assert.strictEqual(res.headers['set-cookie'][0].indexOf('expires='), -1, "cookie is not ephemeral"); 1237 | } 1238 | } 1239 | }, 1240 | "ephemeral cookie": { 1241 | topic: function() { 1242 | var self = this; 1243 | 1244 | var app = express(); 1245 | app.use(cookieSessions({ 1246 | cookieName: 'session', 1247 | duration: 50000, 1248 | activeDuration: 0, 1249 | secret: 'yo', 1250 | cookie: { 1251 | ephemeral: true 1252 | } 1253 | })); 1254 | 1255 | app.get("/foo", function(req, res) { 1256 | req.session.foo = 'foobar'; 1257 | res.send("hello"); 1258 | }); 1259 | 1260 | app.get("/bar", function(req, res) { 1261 | req.session.setDuration(6000, false); 1262 | res.send("hello"); 1263 | }); 1264 | 1265 | shared_browser2 = createBrowser(app); 1266 | shared_browser2.get("/foo", function(res, $) { 1267 | self.callback(null, res); 1268 | }); 1269 | }, 1270 | "doesn't have an expires attribute": function(err, res) { 1271 | assert.strictEqual(res.headers['set-cookie'][0].indexOf('expires='), -1, "cookie is not ephemeral"); 1272 | }, 1273 | "changing to an non-ephemeral one": { 1274 | topic: function() { 1275 | var self = this; 1276 | shared_browser2.get("/bar", function(res, $) { 1277 | shared_browser2.done(); 1278 | self.callback(null, res); 1279 | }); 1280 | }, 1281 | "gains an expires attribute": function(err, res) { 1282 | assert.match(res.headers['set-cookie'][0], /expires/, "cookie is a session cookie"); 1283 | } 1284 | } 1285 | } 1286 | }); 1287 | 1288 | suite.addBatch({ 1289 | "sameSite cookie": { 1290 | topic: function() { 1291 | var self = this; 1292 | var app = express(); 1293 | 1294 | app.use(cookieSessions({ 1295 | cookieName: 'session', 1296 | secret: 'yo', 1297 | activeDuration: 0, 1298 | cookie: { 1299 | sameSite: 'lax' 1300 | } 1301 | })); 1302 | 1303 | app.get("/foo", function(req, res) { 1304 | req.session.foo = 'foobar'; 1305 | res.send("hello"); 1306 | }); 1307 | 1308 | var browser = createBrowser(app); 1309 | browser.get("/foo", function(res, $) { 1310 | browser.done(); 1311 | self.callback(null, res); 1312 | }); 1313 | }, 1314 | "has samesite attribute": function(err, res) { 1315 | assert.match(res.headers['set-cookie'][0], /samesite=lax/, "cookie uses samesite"); 1316 | } 1317 | } 1318 | }); 1319 | 1320 | var sixtyFourByteKey = Buffer.from( 1321 | '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', 1322 | 'binary' 1323 | ); 1324 | var HMAC_EXPECT = { 1325 | // aligned so you can see the dropN effect: 1326 | 'sha256': 1327 | 'PRYaxV/8RkMyIT/Ib+tIUOWiSn+0EvodJ5rtG1FQHz0=', 1328 | 'sha256-drop128': 1329 | 'PRYaxV/8RkMyIT/Ib+tIUA==', 1330 | 'sha384': 1331 | 'MND9nz6pxbQC5m41ZPRXhJIuqTj9/hu4gtWZ8t8LgdFLQFWQfC8jhijB0NHLpeA7', 1332 | 'sha384-drop192': 1333 | 'MND9nz6pxbQC5m41ZPRXhJIuqTj9/hu4', 1334 | 'sha512': 1335 | 'Hr4KLVLyglIwQ43C9U2bmieWBVLnD/F+lzCSF072Ds2b87MK+gbnR0p75A+I+5ez+aiemMGuMZyKVAUWfMMaUA==', 1336 | 'sha512-drop256': 1337 | 'Hr4KLVLyglIwQ43C9U2bmieWBVLnD/F+lzCSF072Ds0=' 1338 | }; 1339 | 1340 | function testHmac(algo) { 1341 | var block = {}; 1342 | block.topic = function() { 1343 | var opts = { 1344 | signatureAlgorithm: algo, 1345 | signatureKey: sixtyFourByteKey 1346 | }; 1347 | var iv = Buffer.from('01234567890abcdef','binary'); // 128-bits 1348 | var ciphertext = Buffer.from('0123456789abcdef0123','binary'); 1349 | var duration = 876543210; 1350 | var createdAt = 1234567890; 1351 | 1352 | return cookieSessions.util.computeHmac( 1353 | opts, iv, ciphertext, duration, createdAt 1354 | ).toString('base64'); 1355 | }; 1356 | 1357 | block['equals test vector'] = function(val) { 1358 | assert.equal(val, HMAC_EXPECT[algo]); 1359 | }; 1360 | 1361 | return block; 1362 | } 1363 | 1364 | suite.addBatch({ 1365 | "computeHmac": { 1366 | "sha256": testHmac('sha256'), 1367 | "sha256-drop128": testHmac('sha256-drop128'), 1368 | "sha384": testHmac('sha384'), 1369 | "sha384-drop192": testHmac('sha384-drop192'), 1370 | "sha512": testHmac('sha512'), 1371 | "sha512-drop256": testHmac('sha512-drop256'), 1372 | } 1373 | }); 1374 | 1375 | suite.export(module); 1376 | --------------------------------------------------------------------------------