diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..cdb36c1b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,11 @@ +# http://editorconfig.org +root = true + +[*] +charset = utf-8 +insert_final_newline = true +trim_trailing_whitespace = true + +[{*.js,*.json,*.yml}] +indent_size = 2 +indent_style = space diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 00000000..76b1021d --- /dev/null +++ b/.eslintignore @@ -0,0 +1,3 @@ +.nyc_output +coverage +node_modules diff --git a/.eslintrc.yml b/.eslintrc.yml new file mode 100644 index 00000000..b6b9f62f --- /dev/null +++ b/.eslintrc.yml @@ -0,0 +1,15 @@ +root: true +extends: + - plugin:markdown/recommended +plugins: + - markdown +overrides: + - files: '**/*.md' + processor: 'markdown/markdown' +rules: + eol-last: error + eqeqeq: ["error", "always", { "null": "ignore" }] + indent: ["error", 2, { "MemberExpression": "off", "SwitchCase": 1 }] + no-mixed-spaces-and-tabs: error + no-trailing-spaces: error + one-var: ["error", { "initialized": "never" }] diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..7d2c7033 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,22 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly + groups: + github-actions: + patterns: + - "*" + + - package-ecosystem: npm + directory: / + schedule: + interval: monthly + ignore: + - dependency-name: "*" + update-types: ["version-update:semver-major"] + groups: + npm-dependencies: + patterns: + - "*" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..004c35ed --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,264 @@ +name: ci + +on: +- pull_request +- push + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + name: + - Node.js 0.8 + - Node.js 0.10 + - Node.js 0.12 + - io.js 1.x + - io.js 2.x + - io.js 3.x + - Node.js 4.x + - Node.js 5.x + - Node.js 6.x + - Node.js 7.x + - Node.js 8.x + - Node.js 9.x + - Node.js 10.x + - Node.js 11.x + - Node.js 12.x + - Node.js 13.x + - Node.js 14.x + - Node.js 15.x + - Node.js 16.x + - Node.js 17.x + - Node.js 18.x + - Node.js 19.x + - Node.js 20.x + - Node.js 21.x + - Node.js 22.x + - Node.js 23.x + - Node.js 24.x + - Node.js 25.x + + include: + - name: Node.js 0.8 + node-version: "0.8" + npm-i: mocha@2.5.3 supertest@1.1.0 + npm-rm: nyc + + - name: Node.js 0.10 + node-version: "0.10" + npm-i: mocha@2.5.3 nyc@10.3.2 supertest@2.0.0 + + - name: Node.js 0.12 + node-version: "0.12" + npm-i: mocha@2.5.3 nyc@10.3.2 supertest@2.0.0 + + - name: io.js 1.x + node-version: "1.8" + npm-i: mocha@2.5.3 nyc@10.3.2 supertest@2.0.0 + + - name: io.js 2.x + node-version: "2.5" + npm-i: mocha@2.5.3 nyc@10.3.2 supertest@2.0.0 + + - name: io.js 3.x + node-version: "3.3" + npm-i: mocha@2.5.3 nyc@10.3.2 supertest@2.0.0 + + - name: Node.js 4.x + node-version: "4.9" + npm-i: mocha@5.2.0 nyc@11.9.0 supertest@3.4.2 + + - name: Node.js 5.x + node-version: "5.12" + npm-i: mocha@5.2.0 nyc@11.9.0 supertest@3.4.2 + + - name: Node.js 6.x + node-version: "6.17" + npm-i: mocha@6.2.2 nyc@14.1.1 supertest@6.1.6 + + - name: Node.js 7.x + node-version: "7.10" + npm-i: mocha@6.2.2 nyc@14.1.1 supertest@6.1.6 + + - name: Node.js 8.x + node-version: "8.17" + npm-i: mocha@7.2.0 nyc@14.1.1 supertest@6.1.6 + + - name: Node.js 9.x + node-version: "9.11" + npm-i: mocha@7.2.0 nyc@14.1.1 supertest@6.1.6 + + - name: Node.js 10.x + node-version: "10.24" + npm-i: mocha@8.4.0 supertest@6.1.6 + + - name: Node.js 11.x + node-version: "11.15" + npm-i: mocha@8.4.0 supertest@6.1.6 + + - name: Node.js 12.x + node-version: "12.22" + npm-i: mocha@9.2.2 supertest@6.1.6 + + - name: Node.js 13.x + node-version: "13.14" + npm-i: mocha@9.2.2 supertest@6.1.6 + + - name: Node.js 14.x + node-version: "14.21" + + - name: Node.js 15.x + node-version: "15.14" + npm-i: supertest@6.1.6 + + - name: Node.js 16.x + node-version: "16.20" + + - name: Node.js 17.x + node-version: "17.9" + + - name: Node.js 18.x + node-version: "18.19" + + - name: Node.js 19.x + node-version: "19.9" + + - name: Node.js 20.x + node-version: "20" + + - name: Node.js 21.x + node-version: "21" + + - name: Node.js 22.x + node-version: "22" + + - name: Node.js 23.x + node-version: "23" + + - name: Node.js 24.x + node-version: "24" + + - name: Node.js 25.x + node-version: "25" + + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Install Node.js ${{ matrix.node-version }} + shell: bash -eo pipefail -l {0} + run: | + nvm install --default ${{ matrix.node-version }} + if [[ "${{ matrix.node-version }}" == 0.* && "$(cut -d. -f2 <<< "${{ matrix.node-version }}")" -lt 10 ]]; then + nvm install --alias=npm 0.10 + nvm use ${{ matrix.node-version }} + if [[ "$(npm -v)" == 1.1.* ]]; then + nvm exec npm npm install -g npm@1.1 + ln -fs "$(which npm)" "$(dirname "$(nvm which npm)")/npm" + else + sed -i '1s;^.*$;'"$(printf '#!%q' "$(nvm which npm)")"';' "$(readlink -f "$(which npm)")" + fi + npm config set strict-ssl false + fi + dirname "$(nvm which ${{ matrix.node-version }})" >> "$GITHUB_PATH" + + - name: Configure npm + run: | + if [[ "$(npm config get package-lock)" == "true" ]]; then + npm config set package-lock false + else + npm config set shrinkwrap false + fi + + - name: Remove npm module(s) ${{ matrix.npm-rm }} + run: npm rm --silent --save-dev ${{ matrix.npm-rm }} + if: matrix.npm-rm != '' + + - name: Install npm module(s) ${{ matrix.npm-i }} + run: npm install --save-dev ${{ matrix.npm-i }} + if: matrix.npm-i != '' + + - name: Setup Node.js version-specific dependencies + shell: bash + run: | + # eslint for linting + # - remove on Node.js < 12 + if [[ "$(cut -d. -f1 <<< "${{ matrix.node-version }}")" -lt 12 ]]; then + node -pe 'Object.keys(require("./package").devDependencies).join("\n")' | \ + grep -E '^eslint(-|$)' | \ + sort -r | \ + xargs -n1 npm rm --silent --save-dev + fi + + - name: Install Node.js dependencies + run: npm install + + - name: List environment + id: list_env + shell: bash + run: | + echo "node@$(node -v)" + echo "npm@$(npm -v)" + npm -s ls ||: + (npm -s ls --depth=0 ||:) | awk -F'[ @]' 'NR>1 && $2 { print $2 "=" $3 }' >> "$GITHUB_OUTPUT" + + - name: Run tests + shell: bash + run: | + if npm -ps ls nyc | grep -q nyc; then + npm run test-ci + cp coverage/lcov.info "coverage/${{ matrix.node-version }}.lcov" + else + npm test + fi + + - name: Lint code + if: steps.list_env.outputs.eslint != '' + run: npm run lint + + - name: Collect code coverage + if: steps.list_env.outputs.nyc != '' + run: | + if [[ -d ./coverage ]]; then + mv ./coverage "./${{ matrix.node-version }}" + mkdir ./coverage + mv "./${{ matrix.node-version }}" "./coverage/${{ matrix.node-version }}" + fi + + - name: Upload code coverage + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: steps.list_env.outputs.nyc != '' + with: + name: coverage-${{ matrix.node-version }} + path: "./coverage/${{ matrix.node-version }}" + retention-days: 1 + + coverage: + permissions: + checks: write # for coverallsapp/github-action to create new checks + contents: read # for actions/checkout to fetch code + needs: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Install lcov + shell: bash + run: sudo apt-get -y install lcov + + - name: Collect coverage reports + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + path: ./coverage + + - name: Merge coverage reports + shell: bash + run: find ./coverage -name lcov.info -exec printf '-a %q\n' {} \; | xargs lcov -o ./coverage/lcov.info + + - name: Upload coverage report + uses: coverallsapp/github-action@5cbfd81b66ca5d10c19b062c04de0199c215fb6e # master + with: + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..139ec706 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,66 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL" + +on: + push: + branches: ["master"] + pull_request: + # The branches below must be a subset of the branches above + branches: ["master"] + schedule: + - cron: "0 0 * * 1" + +permissions: + contents: read + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3 + with: + languages: javascript + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + # - name: Autobuild + # uses: github/codeql-action/autobuild@3ab4101902695724f9365a384f86c1074d94e18c # v3.24.7 + + # â„šī¸ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + + # If the Autobuild fails above, remove it and uncomment the following three lines. + # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. + + # - run: | + # echo "Run, Build Application using script" + # ./location_of_script_within_repo/buildscript.sh + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3 + with: + category: "/language:javascript" \ No newline at end of file diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 00000000..2bb15b23 --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,73 @@ +# This workflow uses actions that are not certified by GitHub. They are provided +# by a third-party and are governed by separate terms of service, privacy +# policy, and support documentation. + +name: Scorecard supply-chain security + +on: + # For Branch-Protection check. Only the default branch is supported. See + # https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection + branch_protection_rule: + # To guarantee Maintained check is occasionally updated. See + # https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained + schedule: + - cron: '16 21 * * 1' + push: + branches: [ "master" ] + +# Declare default permissions as read only. +permissions: read-all + +jobs: + analysis: + name: Scorecard analysis + runs-on: ubuntu-latest + permissions: + # Needed to upload the results to code-scanning dashboard. + security-events: write + # Needed to publish results and get a badge (see publish_results below). + id-token: write + # Uncomment the permissions below if installing in a private repository. + # contents: read + # actions: read + + steps: + - name: "Checkout code" + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: "Run analysis" + uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 + with: + results_file: results.sarif + results_format: sarif + # (Optional) "write" PAT token. Uncomment the `repo_token` line below if: + # - you want to enable the Branch-Protection check on a *public* repository, or + # - you are installing Scorecard on a *private* repository + # To create the PAT, follow the steps in https://github.com/ossf/scorecard-action#authentication-with-pat. + # repo_token: ${{ secrets.SCORECARD_TOKEN }} + + # Public repositories: + # - Publish results to OpenSSF REST API for easy access by consumers + # - Allows the repository to include the Scorecard badge. + # - See https://github.com/ossf/scorecard-action#publishing-results. + # For private repositories: + # - `publish_results` will always be set to `false`, regardless + # of the value entered here. + publish_results: true + + # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF + # format to the repository Actions tab. + - name: "Upload artifact" + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: SARIF file + path: results.sarif + retention-days: 5 + + # Upload the results to GitHub's code scanning dashboard. + - name: "Upload to code-scanning" + uses: github/codeql-action/upload-sarif@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3 + with: + sarif_file: results.sarif \ No newline at end of file diff --git a/.gitignore b/.gitignore index df9af16b..1b6fef23 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ +.nyc_output coverage node_modules npm-debug.log +package-lock.json +/test/fixtures/server.crt +/test/fixtures/server.key diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index f8b221cf..00000000 --- a/.travis.yml +++ /dev/null @@ -1,10 +0,0 @@ -language: node_js -node_js: - - "0.8" - - "0.10" - - "0.12" - - "1.0" - - "1.6" -sudo: false -script: "npm run-script test-travis" -after_script: "npm install coveralls@2.10.0 && cat ./coverage/lcov.info | coveralls" diff --git a/HISTORY.md b/HISTORY.md index 77859a7f..251d5fa7 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,3 +1,238 @@ +unreleased +========== + + * Enforce RFC 6265 section 5.1.4 path matching for the session cookie path + * Fix `secure: 'auto'` and `sameSite: 'auto'` not being resolved when `cookie` is a function + +1.19.0 / 2026-01-22 +========== + + * Add dynamic cookie options support + * Add sameSite 'auto' support for automatic SameSite attribute configuration + * deps: use tilde notation for dependencies + +1.18.2 / 2025-07-17 +========== + * deps: mocha@10.8.2 + * deps: on-headers@~1.1.0 + - Fix [CVE-2025-7339](https://www.cve.org/CVERecord?id=CVE-2025-7339) ([GHSA-76c9-3jph-rj3q](https://github.com/expressjs/on-headers/security/advisories/GHSA-76c9-3jph-rj3q)) + +1.18.1 / 2024-10-08 +========== + + * deps: cookie@0.7.2 + - Fix object assignment of `hasOwnProperty` + * deps: cookie@0.7.1 + - Allow leading dot for domain + - Although not permitted in the spec, some users expect this to work and user agents ignore the leading dot according to spec + - Add fast path for `serialize` without options, use `obj.hasOwnProperty` when parsing + * deps: cookie@0.7.0 + - perf: parse cookies ~10% faster + - fix: narrow the validation of cookies to match RFC6265 + - fix: add `main` to `package.json` for rspack + +1.18.0 / 2024-01-28 +=================== + + * Add debug log for pathname mismatch + * Add `partitioned` to `cookie` options + * Add `priority` to `cookie` options + * Fix handling errors from setting cookie + * Support any type in `secret` that `crypto.createHmac` supports + * deps: cookie@0.6.0 + - Fix `expires` option to reject invalid dates + - perf: improve default decode speed + - perf: remove slow string split in parse + * deps: cookie-signature@1.0.7 + +1.17.3 / 2022-05-11 +=================== + + * Fix resaving already-saved new session at end of request + * deps: cookie@0.4.2 + +1.17.2 / 2021-05-19 +=================== + + * Fix `res.end` patch to always commit headers + * deps: cookie@0.4.1 + * deps: safe-buffer@5.2.1 + +1.17.1 / 2020-04-16 +=================== + + * Fix internal method wrapping error on failed reloads + +1.17.0 / 2019-10-10 +=================== + + * deps: cookie@0.4.0 + - Add `SameSite=None` support + * deps: safe-buffer@5.2.0 + +1.16.2 / 2019-06-12 +=================== + + * Fix restoring `cookie.originalMaxAge` when store returns `Date` + * deps: parseurl@~1.3.3 + +1.16.1 / 2019-04-11 +=================== + + * Fix error passing `data` option to `Cookie` constructor + * Fix uncaught error from bad session data + +1.16.0 / 2019-04-10 +=================== + + * Catch invalid `cookie.maxAge` value earlier + * Deprecate setting `cookie.maxAge` to a `Date` object + * Fix issue where `resave: false` may not save altered sessions + * Remove `utils-merge` dependency + * Use `safe-buffer` for improved Buffer API + * Use `Set-Cookie` as cookie header name for compatibility + * deps: depd@~2.0.0 + - Replace internal `eval` usage with `Function` constructor + - Use instance methods on `process` to check for listeners + - perf: remove argument reassignment + * deps: on-headers@~1.0.2 + - Fix `res.writeHead` patch missing return value + +1.15.6 / 2017-09-26 +=================== + + * deps: debug@2.6.9 + * deps: parseurl@~1.3.2 + - perf: reduce overhead for full URLs + - perf: unroll the "fast-path" `RegExp` + * deps: uid-safe@~2.1.5 + - perf: remove only trailing `=` + * deps: utils-merge@1.0.1 + +1.15.5 / 2017-08-02 +=================== + + * Fix `TypeError` when `req.url` is an empty string + * deps: depd@~1.1.1 + - Remove unnecessary `Buffer` loading + +1.15.4 / 2017-07-18 +=================== + + * deps: debug@2.6.8 + +1.15.3 / 2017-05-17 +=================== + + * deps: debug@2.6.7 + - deps: ms@2.0.0 + +1.15.2 / 2017-03-26 +=================== + + * deps: debug@2.6.3 + - Fix `DEBUG_MAX_ARRAY_LENGTH` + * deps: uid-safe@~2.1.4 + - Remove `base64-url` dependency + +1.15.1 / 2017-02-10 +=================== + + * deps: debug@2.6.1 + - Fix deprecation messages in WebStorm and other editors + - Undeprecate `DEBUG_FD` set to `1` or `2` + +1.15.0 / 2017-01-22 +=================== + + * Fix detecting modified session when session contains "cookie" property + * Fix resaving already-saved reloaded session at end of request + * deps: crc@3.4.4 + - perf: use `Buffer.from` when available + * deps: debug@2.6.0 + - Allow colors in workers + - Deprecated `DEBUG_FD` environment variable + - Use same color for same namespace + - Fix error when running under React Native + - deps: ms@0.7.2 + * perf: remove unreachable branch in set-cookie method + +1.14.2 / 2016-10-30 +=================== + + * deps: crc@3.4.1 + - Fix deprecation warning in Node.js 7.x + * deps: uid-safe@~2.1.3 + - deps: base64-url@1.3.3 + +1.14.1 / 2016-08-24 +=================== + + * Fix not always resetting session max age before session save + * Fix the cookie `sameSite` option to actually alter the `Set-Cookie` + * deps: uid-safe@~2.1.2 + - deps: base64-url@1.3.2 + +1.14.0 / 2016-07-01 +=================== + + * Correctly inherit from `EventEmitter` class in `Store` base class + * Fix issue where `Set-Cookie` `Expires` was not always updated + * Methods are no longer enumerable on `req.session` object + * deps: cookie@0.3.1 + - Add `sameSite` option + - Improve error message when `encode` is not a function + - Improve error message when `expires` is not a `Date` + - perf: enable strict mode + - perf: use for loop in parse + - perf: use string concatenation for serialization + * deps: parseurl@~1.3.1 + - perf: enable strict mode + * deps: uid-safe@~2.1.1 + - Use `random-bytes` for byte source + - deps: base64-url@1.2.2 + * perf: enable strict mode + * perf: remove argument reassignment + +1.13.0 / 2016-01-10 +=================== + + * Fix `rolling: true` to not set cookie when no session exists + - Better `saveUninitialized: false` + `rolling: true` behavior + * deps: crc@3.4.0 + +1.12.1 / 2015-10-29 +=================== + + * deps: cookie@0.2.3 + - Fix cookie `Max-Age` to never be a floating point number + +1.12.0 / 2015-10-25 +=================== + + * Support the value `'auto'` in the `cookie.secure` option + * deps: cookie@0.2.2 + - Throw on invalid values provided to `serialize` + * deps: depd@~1.1.0 + - Enable strict mode in more places + - Support web browser loading + * deps: on-headers@~1.0.1 + - perf: enable strict mode + +1.11.3 / 2015-05-22 +=================== + + * deps: cookie@0.1.3 + - Slight optimizations + * deps: crc@3.3.0 + +1.11.2 / 2015-05-10 +=================== + + * deps: debug@~2.2.0 + - deps: ms@0.7.1 + * deps: uid-safe@~2.0.0 + 1.11.1 / 2015-04-08 =================== diff --git a/LICENSE b/LICENSE index f24497a5..9b59ff85 100644 --- a/LICENSE +++ b/LICENSE @@ -2,7 +2,7 @@ Copyright (c) 2010 Sencha Inc. Copyright (c) 2011 TJ Holowaychuk -Copyright (c) 2014 Douglas Christopher Wilson +Copyright (c) 2014-2015 Douglas Christopher Wilson Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the diff --git a/README.md b/README.md index 8060de25..162ea14d 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,17 @@ # express-session -[![NPM Version][npm-image]][npm-url] -[![NPM Downloads][downloads-image]][downloads-url] -[![Build Status][travis-image]][travis-url] +[![NPM Version][npm-version-image]][npm-url] +[![NPM Downloads][npm-downloads-image]][node-url] +[![Build Status][ci-image]][ci-url] [![Test Coverage][coveralls-image]][coveralls-url] -[![Gratipay][gratipay-image]][gratipay-url] ## Installation -```bash +This is a [Node.js](https://nodejs.org/en/) module available through the +[npm registry](https://www.npmjs.com/). Installation is done using the +[`npm install` command](https://docs.npmjs.com/getting-started/installing-npm-packages-locally): + +```sh $ npm install express-session ``` @@ -25,9 +28,14 @@ Create a session middleware with the given `options`. **Note** Session data is _not_ saved in the cookie itself, just the session ID. Session data is stored server-side. +**Note** Since version 1.5.0, the [`cookie-parser` middleware](https://www.npmjs.com/package/cookie-parser) +no longer needs to be used for this module to work. This module now directly reads +and writes cookies on `req`/`res`. Using `cookie-parser` may result in issues +if the `secret` is not the same between this module and `cookie-parser`. + **Warning** The default server-side session storage, `MemoryStore`, is _purposely_ not designed for a production environment. It will leak memory under most -conditions, does not scale past a single process, and it meant for debugging and +conditions, does not scale past a single process, and is meant for debugging and developing. For a list of stores, see [compatible session stores](#compatible-session-stores). @@ -38,10 +46,187 @@ For a list of stores, see [compatible session stores](#compatible-session-stores ##### cookie -Settings for the session ID cookie. See the "Cookie options" section below for -more information on the different values. +Settings object for the session ID cookie. The default value is +`{ path: '/', httpOnly: true, secure: false, maxAge: null }`. + +In addition to providing a static object, you can also pass a callback function to dynamically generate the cookie options for each request. The callback receives the `req` object as its argument and should return an object containing the cookie settings. + +```js +var app = express() +app.use(session({ + secret: 'keyboard cat', + resave: false, + saveUninitialized: true, + cookie: function(req) { + var match = req.url.match(/^\/([^/]+)/); + return { + path: match ? '/' + match[1] : '/', + httpOnly: true, + secure: req.secure || false, + maxAge: 60000 + } + } +})) +``` + +The following are options that can be set in this object. + +##### cookie.domain + +Specifies the value for the `Domain` `Set-Cookie` attribute. By default, no domain +is set, and most clients will consider the cookie to apply to only the current +domain. + +##### cookie.expires + +Specifies the `Date` object to be the value for the `Expires` `Set-Cookie` attribute. +By default, no expiration is set, and most clients will consider this a +"non-persistent cookie" and will delete it on a condition like exiting a web browser +application. + +**Note** If both `expires` and `maxAge` are set in the options, then the last one +defined in the object is what is used. + +**Note** The `expires` option should not be set directly; instead only use the `maxAge` +option. + +##### cookie.httpOnly + +Specifies the `boolean` value for the `HttpOnly` `Set-Cookie` attribute. When truthy, +the `HttpOnly` attribute is set, otherwise it is not. By default, the `HttpOnly` +attribute is set. + +**Note** be careful when setting this to `true`, as compliant clients will not allow +client-side JavaScript to see the cookie in `document.cookie`. + +##### cookie.maxAge + +Specifies the `number` (in milliseconds) to use when calculating the `Expires` +`Set-Cookie` attribute. This is done by taking the current server time and adding +`maxAge` milliseconds to the value to calculate an `Expires` datetime. By default, +no maximum age is set. + +**Note** If both `expires` and `maxAge` are set in the options, then the last one +defined in the object is what is used. + +##### cookie.partitioned + +Specifies the `boolean` value for the [`Partitioned` `Set-Cookie`](rfc-cutler-httpbis-partitioned-cookies) +attribute. When truthy, the `Partitioned` attribute is set, otherwise it is not. +By default, the `Partitioned` attribute is not set. + +**Note** This is an attribute that has not yet been fully standardized, and may +change in the future. This also means many clients may ignore this attribute until +they understand it. + +More information about can be found in [the proposal](https://github.com/privacycg/CHIPS). + +##### cookie.path + +Specifies the value for the `Path` `Set-Cookie`. By default, this is set to `'/'`, which +is the root path of the domain. + +Since 1.19.1, path matching follows [RFC 6265 section 5.1.4][rfc-6265-5.1.4]. This means +the session middleware will only activate when the request path is an exact match or falls +under a segment boundary of the cookie path. For example, a cookie path of `/admin` will +match `/admin` and `/admin/users` but will **not** match `/administrator`. Prior versions +used a simple prefix check that did not enforce segment boundaries. + +##### cookie.priority + +Specifies the `string` to be the value for the [`Priority` `Set-Cookie` attribute][rfc-west-cookie-priority-00-4.1]. + + - `'low'` will set the `Priority` attribute to `Low`. + - `'medium'` will set the `Priority` attribute to `Medium`, the default priority when not set. + - `'high'` will set the `Priority` attribute to `High`. -The default value is `{ path: '/', httpOnly: true, secure: false, maxAge: null }`. +More information about the different priority levels can be found in +[the specification][rfc-west-cookie-priority-00-4.1]. + +**Note** This is an attribute that has not yet been fully standardized, and may change in the future. +This also means many clients may ignore this attribute until they understand it. + +##### cookie.sameSite + +Specifies the `boolean` or `string` to be the value for the `SameSite` `Set-Cookie` attribute. +By default, this is `false`. + + - `true` will set the `SameSite` attribute to `Strict` for strict same site enforcement. + - `false` will not set the `SameSite` attribute. + - `'lax'` will set the `SameSite` attribute to `Lax` for lax same site enforcement. + - `'none'` will set the `SameSite` attribute to `None` for an explicit cross-site cookie. + - `'strict'` will set the `SameSite` attribute to `Strict` for strict same site enforcement. + - `'auto'` will set the `SameSite` attribute to `None` for secure connections and `Lax` for non-secure connections. + +More information about the different enforcement levels can be found in +[the specification][rfc-6265bis-03-4.1.2.7]. + +**Note** This is an attribute that has not yet been fully standardized, and may change in +the future. This also means many clients may ignore this attribute until they understand it. + +**Note** There is a [draft spec](https://tools.ietf.org/html/draft-west-cookie-incrementalism-01) +that requires that the `Secure` attribute be set to `true` when the `SameSite` attribute has been +set to `'none'`. Some web browsers or other clients may be adopting this specification. + +The `cookie.sameSite` option can also be set to the special value `'auto'` to have +this setting automatically match the determined security of the connection. When the connection +is secure (HTTPS), the `SameSite` attribute will be set to `None` to enable cross-site usage. +When the connection is not secure (HTTP), the `SameSite` attribute will be set to `Lax` for +better security while maintaining functionality. This is useful when the Express `"trust proxy"` +setting is properly setup to simplify development vs production configuration, particularly +for SAML authentication scenarios. + +##### cookie.secure + +Specifies the `boolean` value for the `Secure` `Set-Cookie` attribute. When truthy, +the `Secure` attribute is set, otherwise it is not. By default, the `Secure` +attribute is not set. + +**Note** be careful when setting this to `true`, as compliant clients will not send +the cookie back to the server in the future if the browser does not have an HTTPS +connection. + +Please note that `secure: true` is a **recommended** option. However, it requires +an https-enabled website, i.e., HTTPS is necessary for secure cookies. If `secure` +is set, and you access your site over HTTP, the cookie will not be set. If you +have your node.js behind a proxy and are using `secure: true`, you need to set +"trust proxy" in express: + +```js +var app = express() +app.set('trust proxy', 1) // trust first proxy +app.use(session({ + secret: 'keyboard cat', + resave: false, + saveUninitialized: true, + cookie: { secure: true } +})) +``` + +For using secure cookies in production, but allowing for testing in development, +the following is an example of enabling this setup based on `NODE_ENV` in express: + +```js +var app = express() +var sess = { + secret: 'keyboard cat', + cookie: {} +} + +if (app.get('env') === 'production') { + app.set('trust proxy', 1) // trust first proxy + sess.cookie.secure = true // serve secure cookies +} + +app.use(session(sess)) +``` + +The `cookie.secure` option can also be set to the special value `'auto'` to have +this setting automatically match the determined security of the connection. Be +careful when using this setting if the site is available both as HTTP and HTTPS, +as once the cookie is set on HTTPS, it will no longer be visible over HTTP. This +is useful when the Express `"trust proxy"` setting is properly setup to simplify +development vs production configuration. ##### genid @@ -50,9 +235,9 @@ a string that will be used as a session ID. The function is given `req` as the first argument if you want to use some value attached to `req` when generating the ID. -The default value is a function which uses the `uid2` library to generate IDs. +The default value is a function which uses the `uid-safe` library to generate IDs. -**NOTE** be careful you generate unique IDs so your sessions do not conflict. +**NOTE** be careful to generate unique IDs so your sessions do not conflict. ```js app.use(session({ @@ -70,9 +255,10 @@ request). The default value is `'connect.sid'`. -**Note** if you have multiple apps running on the same host (hostname + port), -then you need to separate the session cookies from each other. The simplest -method is to simply set different `name`s per app. +**Note** if you have multiple apps running on the same hostname (this is just +the name, i.e. `localhost` or `127.0.0.1`; different schemes and ports do not +name a different hostname), then you need to separate the session cookies from +each other. The simplest method is to simply set different `name`s per app. ##### proxy @@ -84,13 +270,13 @@ The default value is `undefined`. - `true` The "X-Forwarded-Proto" header will be used. - `false` All headers are ignored and the connection is considered secure only if there is a direct TLS/SSL connection. - - `undefined` Use the "trust proxy" setting from express + - `undefined` Uses the "trust proxy" setting from express ##### resave Forces the session to be saved back to the session store, even if the session was never modified during the request. Depending on your store this may be -necessary, but it can also create race conditions where a client has two +necessary, but it can also create race conditions where a client makes two parallel requests to your server and changes made to the session in one request may get overwritten when the other request ends, even if it made no changes (this behavior also depends on what store you're using). @@ -108,16 +294,31 @@ likely need `resave: true`. ##### rolling -Force a cookie to be set on every response. This resets the expiration date. +Force the session identifier cookie to be set on every response. The expiration +is reset to the original [`maxAge`](#cookiemaxage), resetting the expiration +countdown. The default value is `false`. +With this enabled, the session identifier cookie will expire in +[`maxAge`](#cookiemaxage) since the last response was sent instead of in +[`maxAge`](#cookiemaxage) since the session was last modified by the server. + +This is typically used in conjunction with short, non-session-length +[`maxAge`](#cookiemaxage) values to provide a quick timeout of the session data +with reduced potential of it occurring during on going server interactions. + +**Note** When this option is set to `true` but the `saveUninitialized` option is +set to `false`, the cookie will not be set on a response with an uninitialized +session. This option only modifies the behavior when an existing session was +loaded for the request. + ##### saveUninitialized Forces a session that is "uninitialized" to be saved to the store. A session is uninitialized when it is new but not modified. Choosing `false` is useful for implementing login sessions, reducing server storage usage, or complying with -laws that require permission before setting a cookie. Choose `false` will also +laws that require permission before setting a cookie. Choosing `false` will also help with race conditions where a client makes multiple parallel requests without a session. @@ -128,16 +329,35 @@ choose what is appropriate to your use-case. **Note** if you are using Session in conjunction with PassportJS, Passport will add an empty Passport object to the session for use after a user is authenticated, which will be treated as a modification to the session, causing -it to be saved. +it to be saved. *This has been fixed in PassportJS 0.3.0* ##### secret **Required option** -This is the secret used to sign the session ID cookie. This can be either a string -for a single secret, or an array of multiple secrets. If an array of secrets is -provided, only the first element will be used to sign the session ID cookie, while -all the elements will be considered when verifying the signature in requests. +This is the secret used to sign the session ID cookie. The secret can be any type +of value that is supported by Node.js `crypto.createHmac` (like a string or a +`Buffer`). This can be either a single secret, or an array of multiple secrets. If +an array of secrets is provided, only the first element will be used to sign the +session ID cookie, while all the elements will be considered when verifying the +signature in requests. The secret itself should be not easily parsed by a human and +would best be a random set of characters. A best practice may include: + + - The use of environment variables to store the secret, ensuring the secret itself + does not exist in your repository. + - Periodic updates of the secret, while ensuring the previous secret is in the + array. + +Using a secret that cannot be guessed will reduce the ability to hijack a session to +only guessing the session ID (as determined by the `genid` option). + +Changing the secret value will invalidate all existing sessions. In order to rotate +the secret without invalidating sessions, provide an array of secrets, with the new +secret as first element of the array, and including previous secrets as the later +elements. + +**Note** HMAC-256 is used to sign the session ID. For this reason, the secret should +contain at least 32 bytes of entropy. ##### store @@ -154,48 +374,6 @@ The default value is `'keep'`. - `'keep'` The session in the store will be kept, but modifications made during the request are ignored and not saved. -#### Cookie options - -**Note** Since version 1.5.0, the [`cookie-parser` middleware](https://www.npmjs.com/package/cookie-parser) -no longer needs to be used for this module to work. This module now directly reads -and writes cookies on `req`/`res`. Using `cookie-parser` may result in issues -if the `secret` is not the same between this module and `cookie-parser`. - -Please note that `secure: true` is a **recommended** option. However, it requires an https-enabled website, i.e., HTTPS is necessary for secure cookies. -If `secure` is set, and you access your site over HTTP, the cookie will not be set. If you have your node.js behind a proxy and are using `secure: true`, you need to set "trust proxy" in express: - -```js -var app = express() -app.set('trust proxy', 1) // trust first proxy -app.use(session({ - secret: 'keyboard cat', - resave: false, - saveUninitialized: true, - cookie: { secure: true } -})) -``` - -For using secure cookies in production, but allowing for testing in development, the following is an example of enabling this setup based on `NODE_ENV` in express: - -```js -var app = express() -var sess = { - secret: 'keyboard cat', - cookie: {} -} - -if (app.get('env') === 'production') { - app.set('trust proxy', 1) // trust first proxy - sess.cookie.secure = true // serve secure cookies -} - -app.use(session(sess)) -``` - -By default `cookie.maxAge` is `null`, meaning no "expires" parameter is set -so the cookie becomes a browser-session cookie. When the user closes the -browser the cookie (and session) will be removed. - ### req.session To store or access session data, simply use the request property `req.session`, @@ -203,27 +381,29 @@ which is (generally) serialized as JSON by the store, so nested objects are typically fine. For example below is a user-specific view counter: ```js +// Use the session middleware app.use(session({ secret: 'keyboard cat', cookie: { maxAge: 60000 }})) -app.use(function(req, res, next) { - var sess = req.session - if (sess.views) { - sess.views++ +// Access the session as req.session +app.get('/', function(req, res, next) { + if (req.session.views) { + req.session.views++ res.setHeader('Content-Type', 'text/html') - res.write('

views: ' + sess.views + '

') - res.write('

expires in: ' + (sess.cookie.maxAge / 1000) + 's

') + res.write('

views: ' + req.session.views + '

') + res.write('

expires in: ' + (req.session.cookie.maxAge / 1000) + 's

') res.end() } else { - sess.views = 1 + req.session.views = 1 res.end('welcome to the session demo. refresh!') } }) ``` -#### Session.regenerate() +#### Session.regenerate(callback) -To regenerate the session simply invoke the method, once complete -a new SID and `Session` instance will be initialized at `req.session`. +To regenerate the session simply invoke the method. Once complete, +a new SID and `Session` instance will be initialized at `req.session` +and the `callback` will be invoked. ```js req.session.regenerate(function(err) { @@ -231,9 +411,10 @@ req.session.regenerate(function(err) { }) ``` -#### Session.destroy() +#### Session.destroy(callback) -Destroys the session, removing `req.session`, will be re-generated next request. +Destroys the session and will unset the `req.session` property. +Once complete, the `callback` will be invoked. ```js req.session.destroy(function(err) { @@ -241,9 +422,10 @@ req.session.destroy(function(err) { }) ``` -#### Session.reload() +#### Session.reload(callback) -Reloads the session data. +Reloads the session data from the store and re-populates the +`req.session` object. Once complete, the `callback` will be invoked. ```js req.session.reload(function(err) { @@ -251,7 +433,19 @@ req.session.reload(function(err) { }) ``` -#### Session.save() +#### Session.save(callback) + +Save the session back to the store, replacing the contents on the store with the +contents in memory (though a store may do something else--consult the store's +documentation for exact behavior). + +This method is automatically called at the end of the HTTP response if the +session data has been altered (though this behavior can be altered with various +options in the middleware constructor). Because of this, typically this method +does not need to be called. + +There are some cases where it is useful to call this method, for example, +redirects, long-lived requests or in WebSockets. ```js req.session.save(function(err) { @@ -264,6 +458,13 @@ req.session.save(function(err) { Updates the `.maxAge` property. Typically this is not necessary to call, as the session middleware does this for you. +### req.session.id + +Each session has a unique ID associated with it. This property is an +alias of [`req.sessionID`](#reqsessionid-1) and cannot be modified. +It has been added to make the session ID accessible from the `session` +object. + ### req.session.cookie Each session has a unique cookie object accompany it. This allows @@ -286,43 +487,421 @@ req.session.cookie.maxAge = hour For example when `maxAge` is set to `60000` (one minute), and 30 seconds has elapsed it will return `30000` until the current request has completed, -at which time `req.session.touch()` is called to reset `req.session.maxAge` -to its original value. +at which time `req.session.touch()` is called to reset +`req.session.cookie.maxAge` to its original value. ```js req.session.cookie.maxAge // => 30000 ``` -## Session Store Implementation +#### Cookie.originalMaxAge -Every session store _must_ be an `EventEmitter` and implement the following -methods: +The `req.session.cookie.originalMaxAge` property returns the original +`maxAge` (time-to-live), in milliseconds, of the session cookie. - - `.get(sid, callback)` - - `.set(sid, session, callback)` - - `.destroy(sid, callback)` +### req.sessionID -Recommended methods include, but are not limited to: +To get the ID of the loaded session, access the request property +`req.sessionID`. This is simply a read-only value set when a session +is loaded/created. - - `.touch(sid, session, callback)` - - `.length(callback)` - - `.clear(callback)` +## Session Store Implementation + +Every session store _must_ be an `EventEmitter` and implement specific +methods. The following methods are the list of **required**, **recommended**, +and **optional**. + + * Required methods are ones that this module will always call on the store. + * Recommended methods are ones that this module will call on the store if + available. + * Optional methods are ones this module does not call at all, but helps + present uniform stores to users. For an example implementation view the [connect-redis](http://github.com/visionmedia/connect-redis) repo. +### store.all(callback) + +**Optional** + +This optional method is used to get all sessions in the store as an array. The +`callback` should be called as `callback(error, sessions)`. + +### store.destroy(sid, callback) + +**Required** + +This required method is used to destroy/delete a session from the store given +a session ID (`sid`). The `callback` should be called as `callback(error)` once +the session is destroyed. + +### store.clear(callback) + +**Optional** + +This optional method is used to delete all sessions from the store. The +`callback` should be called as `callback(error)` once the store is cleared. + +### store.length(callback) + +**Optional** + +This optional method is used to get the count of all sessions in the store. +The `callback` should be called as `callback(error, len)`. + +### store.get(sid, callback) + +**Required** + +This required method is used to get a session from the store given a session +ID (`sid`). The `callback` should be called as `callback(error, session)`. + +The `session` argument should be a session if found, otherwise `null` or +`undefined` if the session was not found (and there was no error). A special +case is made when `error.code === 'ENOENT'` to act like `callback(null, null)`. + +### store.set(sid, session, callback) + +**Required** + +This required method is used to upsert a session into the store given a +session ID (`sid`) and session (`session`) object. The callback should be +called as `callback(error)` once the session has been set in the store. + +### store.touch(sid, session, callback) + +**Recommended** + +This recommended method is used to "touch" a given session given a +session ID (`sid`) and session (`session`) object. The `callback` should be +called as `callback(error)` once the session has been touched. + +This is primarily used when the store will automatically delete idle sessions +and this method is used to signal to the store the given session is active, +potentially resetting the idle timer. + ## Compatible Session Stores The following modules implement a session store that is compatible with this module. Please make a PR to add additional modules :) - * [connect-mongo](https://www.npmjs.com/package/connect-mongo) A MongoDB-based - session store. - * [connect-redis](https://www.npmjs.com/package/connect-redis) A Redis-based - session store. - * [session-file-store](https://www.npmjs.com/package/session-file-store) A file - system-based session store. +[![★][aerospike-session-store-image] aerospike-session-store][aerospike-session-store-url] A session store using [Aerospike](http://www.aerospike.com/). + +[aerospike-session-store-url]: https://www.npmjs.com/package/aerospike-session-store +[aerospike-session-store-image]: https://badgen.net/github/stars/aerospike/aerospike-session-store-expressjs?label=%E2%98%85 + +[![★][better-sqlite3-session-store-image] better-sqlite3-session-store][better-sqlite3-session-store-url] A session store based on [better-sqlite3](https://github.com/JoshuaWise/better-sqlite3). + +[better-sqlite3-session-store-url]: https://www.npmjs.com/package/better-sqlite3-session-store +[better-sqlite3-session-store-image]: https://badgen.net/github/stars/timdaub/better-sqlite3-session-store?label=%E2%98%85 -## Example +[![★][cassandra-store-image] cassandra-store][cassandra-store-url] An Apache Cassandra-based session store. + +[cassandra-store-url]: https://www.npmjs.com/package/cassandra-store +[cassandra-store-image]: https://badgen.net/github/stars/webcc/cassandra-store?label=%E2%98%85 + +[![★][cluster-store-image] cluster-store][cluster-store-url] A wrapper for using in-process / embedded +stores - such as SQLite (via knex), leveldb, files, or memory - with node cluster (desirable for Raspberry Pi 2 +and other multi-core embedded devices). + +[cluster-store-url]: https://www.npmjs.com/package/cluster-store +[cluster-store-image]: https://badgen.net/github/stars/coolaj86/cluster-store?label=%E2%98%85 + +[![★][connect-arango-image] connect-arango][connect-arango-url] An ArangoDB-based session store. + +[connect-arango-url]: https://www.npmjs.com/package/connect-arango +[connect-arango-image]: https://badgen.net/github/stars/AlexanderArvidsson/connect-arango?label=%E2%98%85 + +[![★][connect-azuretables-image] connect-azuretables][connect-azuretables-url] An [Azure Table Storage](https://azure.microsoft.com/en-gb/services/storage/tables/)-based session store. + +[connect-azuretables-url]: https://www.npmjs.com/package/connect-azuretables +[connect-azuretables-image]: https://badgen.net/github/stars/mike-goodwin/connect-azuretables?label=%E2%98%85 + +[![★][connect-cloudant-store-image] connect-cloudant-store][connect-cloudant-store-url] An [IBM Cloudant](https://cloudant.com/)-based session store. + +[connect-cloudant-store-url]: https://www.npmjs.com/package/connect-cloudant-store +[connect-cloudant-store-image]: https://badgen.net/github/stars/adriantanasa/connect-cloudant-store?label=%E2%98%85 + +[![★][connect-cosmosdb-image] connect-cosmosdb][connect-cosmosdb-url] An Azure [Cosmos DB](https://azure.microsoft.com/en-us/products/cosmos-db/)-based session store. + +[connect-cosmosdb-url]: https://www.npmjs.com/package/connect-cosmosdb +[connect-cosmosdb-image]: https://badgen.net/github/stars/thekillingspree/connect-cosmosdb?label=%E2%98%85 + +[![★][connect-couchbase-image] connect-couchbase][connect-couchbase-url] A [couchbase](http://www.couchbase.com/)-based session store. + +[connect-couchbase-url]: https://www.npmjs.com/package/connect-couchbase +[connect-couchbase-image]: https://badgen.net/github/stars/christophermina/connect-couchbase?label=%E2%98%85 + +[![★][connect-datacache-image] connect-datacache][connect-datacache-url] An [IBM Bluemix Data Cache](http://www.ibm.com/cloud-computing/bluemix/)-based session store. + +[connect-datacache-url]: https://www.npmjs.com/package/connect-datacache +[connect-datacache-image]: https://badgen.net/github/stars/adriantanasa/connect-datacache?label=%E2%98%85 + +[![★][@google-cloud/connect-datastore-image] @google-cloud/connect-datastore][@google-cloud/connect-datastore-url] A [Google Cloud Datastore](https://cloud.google.com/datastore/docs/concepts/overview)-based session store. + +[@google-cloud/connect-datastore-url]: https://www.npmjs.com/package/@google-cloud/connect-datastore +[@google-cloud/connect-datastore-image]: https://badgen.net/github/stars/GoogleCloudPlatform/cloud-datastore-session-node?label=%E2%98%85 + +[![★][connect-db2-image] connect-db2][connect-db2-url] An IBM DB2-based session store built using [ibm_db](https://www.npmjs.com/package/ibm_db) module. + +[connect-db2-url]: https://www.npmjs.com/package/connect-db2 +[connect-db2-image]: https://badgen.net/github/stars/wallali/connect-db2?label=%E2%98%85 + +[![★][connect-dynamodb-image] connect-dynamodb][connect-dynamodb-url] A DynamoDB-based session store. + +[connect-dynamodb-url]: https://www.npmjs.com/package/connect-dynamodb +[connect-dynamodb-image]: https://badgen.net/github/stars/ca98am79/connect-dynamodb?label=%E2%98%85 + +[![★][@google-cloud/connect-firestore-image] @google-cloud/connect-firestore][@google-cloud/connect-firestore-url] A [Google Cloud Firestore](https://cloud.google.com/firestore/docs/overview)-based session store. + +[@google-cloud/connect-firestore-url]: https://www.npmjs.com/package/@google-cloud/connect-firestore +[@google-cloud/connect-firestore-image]: https://badgen.net/github/stars/googleapis/nodejs-firestore-session?label=%E2%98%85 + +[![★][connect-hazelcast-image] connect-hazelcast][connect-hazelcast-url] Hazelcast session store for Connect and Express. + +[connect-hazelcast-url]: https://www.npmjs.com/package/connect-hazelcast +[connect-hazelcast-image]: https://badgen.net/github/stars/huseyinbabal/connect-hazelcast?label=%E2%98%85 + +[![★][connect-loki-image] connect-loki][connect-loki-url] A Loki.js-based session store. + +[connect-loki-url]: https://www.npmjs.com/package/connect-loki +[connect-loki-image]: https://badgen.net/github/stars/Requarks/connect-loki?label=%E2%98%85 + +[![★][connect-lowdb-image] connect-lowdb][connect-lowdb-url] A lowdb-based session store. + +[connect-lowdb-url]: https://www.npmjs.com/package/connect-lowdb +[connect-lowdb-image]: https://badgen.net/github/stars/travishorn/connect-lowdb?label=%E2%98%85 + +[![★][connect-memcached-image] connect-memcached][connect-memcached-url] A memcached-based session store. + +[connect-memcached-url]: https://www.npmjs.com/package/connect-memcached +[connect-memcached-image]: https://badgen.net/github/stars/balor/connect-memcached?label=%E2%98%85 + +[![★][connect-memjs-image] connect-memjs][connect-memjs-url] A memcached-based session store using +[memjs](https://www.npmjs.com/package/memjs) as the memcached client. + +[connect-memjs-url]: https://www.npmjs.com/package/connect-memjs +[connect-memjs-image]: https://badgen.net/github/stars/liamdon/connect-memjs?label=%E2%98%85 + +[![★][connect-ml-image] connect-ml][connect-ml-url] A MarkLogic Server-based session store. + +[connect-ml-url]: https://www.npmjs.com/package/connect-ml +[connect-ml-image]: https://badgen.net/github/stars/bluetorch/connect-ml?label=%E2%98%85 + +[![★][connect-monetdb-image] connect-monetdb][connect-monetdb-url] A MonetDB-based session store. + +[connect-monetdb-url]: https://www.npmjs.com/package/connect-monetdb +[connect-monetdb-image]: https://badgen.net/github/stars/MonetDB/npm-connect-monetdb?label=%E2%98%85 + +[![★][connect-mongo-image] connect-mongo][connect-mongo-url] A MongoDB-based session store. + +[connect-mongo-url]: https://www.npmjs.com/package/connect-mongo +[connect-mongo-image]: https://badgen.net/github/stars/kcbanner/connect-mongo?label=%E2%98%85 + +[![★][connect-mongodb-session-image] connect-mongodb-session][connect-mongodb-session-url] Lightweight MongoDB-based session store built and maintained by MongoDB. + +[connect-mongodb-session-url]: https://www.npmjs.com/package/connect-mongodb-session +[connect-mongodb-session-image]: https://badgen.net/github/stars/mongodb-js/connect-mongodb-session?label=%E2%98%85 + +[![★][connect-mssql-v2-image] connect-mssql-v2][connect-mssql-v2-url] A Microsoft SQL Server-based session store based on [connect-mssql](https://www.npmjs.com/package/connect-mssql). + +[connect-mssql-v2-url]: https://www.npmjs.com/package/connect-mssql-v2 +[connect-mssql-v2-image]: https://badgen.net/github/stars/jluboff/connect-mssql-v2?label=%E2%98%85 + +[![★][connect-neo4j-image] connect-neo4j][connect-neo4j-url] A [Neo4j](https://neo4j.com)-based session store. + +[connect-neo4j-url]: https://www.npmjs.com/package/connect-neo4j +[connect-neo4j-image]: https://badgen.net/github/stars/MaxAndersson/connect-neo4j?label=%E2%98%85 + +[![★][connect-ottoman-image] connect-ottoman][connect-ottoman-url] A [couchbase ottoman](http://www.couchbase.com/)-based session store. + +[connect-ottoman-url]: https://www.npmjs.com/package/connect-ottoman +[connect-ottoman-image]: https://badgen.net/github/stars/noiissyboy/connect-ottoman?label=%E2%98%85 + +[![★][connect-pg-simple-image] connect-pg-simple][connect-pg-simple-url] A PostgreSQL-based session store. + +[connect-pg-simple-url]: https://www.npmjs.com/package/connect-pg-simple +[connect-pg-simple-image]: https://badgen.net/github/stars/voxpelli/node-connect-pg-simple?label=%E2%98%85 + +[![★][connect-redis-image] connect-redis][connect-redis-url] A Redis-based session store. + +[connect-redis-url]: https://www.npmjs.com/package/connect-redis +[connect-redis-image]: https://badgen.net/github/stars/tj/connect-redis?label=%E2%98%85 + +[![★][connect-session-firebase-image] connect-session-firebase][connect-session-firebase-url] A session store based on the [Firebase Realtime Database](https://firebase.google.com/docs/database/) + +[connect-session-firebase-url]: https://www.npmjs.com/package/connect-session-firebase +[connect-session-firebase-image]: https://badgen.net/github/stars/benweier/connect-session-firebase?label=%E2%98%85 + +[![★][connect-session-knex-image] connect-session-knex][connect-session-knex-url] A session store using +[Knex.js](http://knexjs.org/), which is a SQL query builder for PostgreSQL, MySQL, MariaDB, SQLite3, and Oracle. + +[connect-session-knex-url]: https://www.npmjs.com/package/connect-session-knex +[connect-session-knex-image]: https://badgen.net/github/stars/llambda/connect-session-knex?label=%E2%98%85 + +[![★][connect-session-sequelize-image] connect-session-sequelize][connect-session-sequelize-url] A session store using +[Sequelize.js](https://sequelize.org/), which is a Node.js / io.js ORM for PostgreSQL, MySQL, SQLite and MSSQL. + +[connect-session-sequelize-url]: https://www.npmjs.com/package/connect-session-sequelize +[connect-session-sequelize-image]: https://badgen.net/github/stars/mweibel/connect-session-sequelize?label=%E2%98%85 + +[![★][connect-sqlite3-image] connect-sqlite3][connect-sqlite3-url] A [SQLite3](https://github.com/mapbox/node-sqlite3) session store modeled after the TJ's `connect-redis` store. + +[connect-sqlite3-url]: https://www.npmjs.com/package/connect-sqlite3 +[connect-sqlite3-image]: https://badgen.net/github/stars/rawberg/connect-sqlite3?label=%E2%98%85 + +[![★][connect-typeorm-image] connect-typeorm][connect-typeorm-url] A [TypeORM](https://github.com/typeorm/typeorm)-based session store. + +[connect-typeorm-url]: https://www.npmjs.com/package/connect-typeorm +[connect-typeorm-image]: https://badgen.net/github/stars/makepost/connect-typeorm?label=%E2%98%85 + +[![★][couchdb-expression-image] couchdb-expression][couchdb-expression-url] A [CouchDB](https://couchdb.apache.org/)-based session store. + +[couchdb-expression-url]: https://www.npmjs.com/package/couchdb-expression +[couchdb-expression-image]: https://badgen.net/github/stars/tkshnwesper/couchdb-expression?label=%E2%98%85 + +[![★][dynamodb-store-image] dynamodb-store][dynamodb-store-url] A DynamoDB-based session store. + +[dynamodb-store-url]: https://www.npmjs.com/package/dynamodb-store +[dynamodb-store-image]: https://badgen.net/github/stars/rafaelrpinto/dynamodb-store?label=%E2%98%85 + +[![★][express-etcd-image] express-etcd][express-etcd-url] An [etcd](https://github.com/stianeikeland/node-etcd) based session store. + +[express-etcd-url]: https://www.npmjs.com/package/express-etcd +[express-etcd-image]: https://badgen.net/github/stars/gildean/express-etcd?label=%E2%98%85 + +[![★][express-mysql-session-image] express-mysql-session][express-mysql-session-url] A session store using native +[MySQL](https://www.mysql.com/) via the [node-mysql](https://github.com/felixge/node-mysql) module. + +[express-mysql-session-url]: https://www.npmjs.com/package/express-mysql-session +[express-mysql-session-image]: https://badgen.net/github/stars/chill117/express-mysql-session?label=%E2%98%85 + +[![★][express-nedb-session-image] express-nedb-session][express-nedb-session-url] A NeDB-based session store. + +[express-nedb-session-url]: https://www.npmjs.com/package/express-nedb-session +[express-nedb-session-image]: https://badgen.net/github/stars/louischatriot/express-nedb-session?label=%E2%98%85 + +[![★][express-oracle-session-image] express-oracle-session][express-oracle-session-url] A session store using native +[oracle](https://www.oracle.com/) via the [node-oracledb](https://www.npmjs.com/package/oracledb) module. + +[express-oracle-session-url]: https://www.npmjs.com/package/express-oracle-session +[express-oracle-session-image]: https://badgen.net/github/stars/slumber86/express-oracle-session?label=%E2%98%85 + +[![★][express-session-cache-manager-image] express-session-cache-manager][express-session-cache-manager-url] +A store that implements [cache-manager](https://www.npmjs.com/package/cache-manager), which supports +a [variety of storage types](https://www.npmjs.com/package/cache-manager#store-engines). + +[express-session-cache-manager-url]: https://www.npmjs.com/package/express-session-cache-manager +[express-session-cache-manager-image]: https://badgen.net/github/stars/theogravity/express-session-cache-manager?label=%E2%98%85 + +[![★][express-session-etcd3-image] express-session-etcd3][express-session-etcd3-url] An [etcd3](https://github.com/mixer/etcd3) based session store. + +[express-session-etcd3-url]: https://www.npmjs.com/package/express-session-etcd3 +[express-session-etcd3-image]: https://badgen.net/github/stars/willgm/express-session-etcd3?label=%E2%98%85 + +[![★][express-session-level-image] express-session-level][express-session-level-url] A [LevelDB](https://github.com/Level/levelup) based session store. + +[express-session-level-url]: https://www.npmjs.com/package/express-session-level +[express-session-level-image]: https://badgen.net/github/stars/tgohn/express-session-level?label=%E2%98%85 + +[![★][express-session-rsdb-image] express-session-rsdb][express-session-rsdb-url] Session store based on Rocket-Store: A very simple, super fast and yet powerful, flat file database. + +[express-session-rsdb-url]: https://www.npmjs.com/package/express-session-rsdb +[express-session-rsdb-image]: https://badgen.net/github/stars/paragi/express-session-rsdb?label=%E2%98%85 + +[![★][express-sessions-image] express-sessions][express-sessions-url] A session store supporting both MongoDB and Redis. + +[express-sessions-url]: https://www.npmjs.com/package/express-sessions +[express-sessions-image]: https://badgen.net/github/stars/konteck/express-sessions?label=%E2%98%85 + +[![★][firestore-store-image] firestore-store][firestore-store-url] A [Firestore](https://github.com/hendrysadrak/firestore-store)-based session store. + +[firestore-store-url]: https://www.npmjs.com/package/firestore-store +[firestore-store-image]: https://badgen.net/github/stars/hendrysadrak/firestore-store?label=%E2%98%85 + +[![★][fortune-session-image] fortune-session][fortune-session-url] A [Fortune.js](https://github.com/fortunejs/fortune) +based session store. Supports all backends supported by Fortune (MongoDB, Redis, Postgres, NeDB). + +[fortune-session-url]: https://www.npmjs.com/package/fortune-session +[fortune-session-image]: https://badgen.net/github/stars/aliceklipper/fortune-session?label=%E2%98%85 + +[![★][hazelcast-store-image] hazelcast-store][hazelcast-store-url] A Hazelcast-based session store built on the [Hazelcast Node Client](https://www.npmjs.com/package/hazelcast-client). + +[hazelcast-store-url]: https://www.npmjs.com/package/hazelcast-store +[hazelcast-store-image]: https://badgen.net/github/stars/jackspaniel/hazelcast-store?label=%E2%98%85 + +[![★][level-session-store-image] level-session-store][level-session-store-url] A LevelDB-based session store. + +[level-session-store-url]: https://www.npmjs.com/package/level-session-store +[level-session-store-image]: https://badgen.net/github/stars/toddself/level-session-store?label=%E2%98%85 + +[![★][lowdb-session-store-image] lowdb-session-store][lowdb-session-store-url] A [lowdb](https://www.npmjs.com/package/lowdb)-based session store. + +[lowdb-session-store-url]: https://www.npmjs.com/package/lowdb-session-store +[lowdb-session-store-image]: https://badgen.net/github/stars/fhellwig/lowdb-session-store?label=%E2%98%85 + +[![★][medea-session-store-image] medea-session-store][medea-session-store-url] A Medea-based session store. + +[medea-session-store-url]: https://www.npmjs.com/package/medea-session-store +[medea-session-store-image]: https://badgen.net/github/stars/BenjaminVadant/medea-session-store?label=%E2%98%85 + +[![★][memorystore-image] memorystore][memorystore-url] A memory session store made for production. + +[memorystore-url]: https://www.npmjs.com/package/memorystore +[memorystore-image]: https://badgen.net/github/stars/roccomuso/memorystore?label=%E2%98%85 + +[![★][mssql-session-store-image] mssql-session-store][mssql-session-store-url] A SQL Server-based session store. + +[mssql-session-store-url]: https://www.npmjs.com/package/mssql-session-store +[mssql-session-store-image]: https://badgen.net/github/stars/jwathen/mssql-session-store?label=%E2%98%85 + +[![★][nedb-session-store-image] nedb-session-store][nedb-session-store-url] An alternate NeDB-based (either in-memory or file-persisted) session store. + +[nedb-session-store-url]: https://www.npmjs.com/package/nedb-session-store +[nedb-session-store-image]: https://badgen.net/github/stars/JamesMGreene/nedb-session-store?label=%E2%98%85 + +[![★][@quixo3/prisma-session-store-image] @quixo3/prisma-session-store][@quixo3/prisma-session-store-url] A session store for the [Prisma Framework](https://www.prisma.io). + +[@quixo3/prisma-session-store-url]: https://www.npmjs.com/package/@quixo3/prisma-session-store +[@quixo3/prisma-session-store-image]: https://badgen.net/github/stars/kleydon/prisma-session-store?label=%E2%98%85 + +[![★][restsession-image] restsession][restsession-url] Store sessions utilizing a RESTful API + +[restsession-url]: https://www.npmjs.com/package/restsession +[restsession-image]: https://badgen.net/github/stars/jankal/restsession?label=%E2%98%85 + +[![★][sequelstore-connect-image] sequelstore-connect][sequelstore-connect-url] A session store using [Sequelize.js](https://sequelize.org/). + +[sequelstore-connect-url]: https://www.npmjs.com/package/sequelstore-connect +[sequelstore-connect-image]: https://badgen.net/github/stars/MattMcFarland/sequelstore-connect?label=%E2%98%85 + +[![★][session-file-store-image] session-file-store][session-file-store-url] A file system-based session store. + +[session-file-store-url]: https://www.npmjs.com/package/session-file-store +[session-file-store-image]: https://badgen.net/github/stars/valery-barysok/session-file-store?label=%E2%98%85 + +[![★][session-pouchdb-store-image] session-pouchdb-store][session-pouchdb-store-url] Session store for PouchDB / CouchDB. Accepts embedded, custom, or remote PouchDB instance and realtime synchronization. + +[session-pouchdb-store-url]: https://www.npmjs.com/package/session-pouchdb-store +[session-pouchdb-store-image]: https://badgen.net/github/stars/solzimer/session-pouchdb-store?label=%E2%98%85 + +[![★][@databunker/session-store-image] @databunker/session-store][@databunker/session-store-url] A [Databunker](https://databunker.org/)-based encrypted session store. + +[@databunker/session-store-url]: https://www.npmjs.com/package/@databunker/session-store +[@databunker/session-store-image]: https://badgen.net/github/stars/securitybunker/databunker-session-store?label=%E2%98%85 + +[![★][sessionstore-image] sessionstore][sessionstore-url] A session store that works with various databases. + +[sessionstore-url]: https://www.npmjs.com/package/sessionstore +[sessionstore-image]: https://badgen.net/github/stars/adrai/sessionstore?label=%E2%98%85 + +[![★][tch-nedb-session-image] tch-nedb-session][tch-nedb-session-url] A file system session store based on NeDB. + +[tch-nedb-session-url]: https://www.npmjs.com/package/tch-nedb-session +[tch-nedb-session-image]: https://badgen.net/github/stars/tomaschyly/NeDBSession?label=%E2%98%85 + +## Examples + +### View counter A simple example using `express-session` to store page views for a user. @@ -340,17 +919,15 @@ app.use(session({ })) app.use(function (req, res, next) { - var views = req.session.views - - if (!views) { - views = req.session.views = {} + if (!req.session.views) { + req.session.views = {} } // get the url pathname var pathname = parseurl(req).pathname // count the views - views[pathname] = (views[pathname] || 0) + 1 + req.session.views[pathname] = (req.session.views[pathname] || 0) + 1 next() }) @@ -362,19 +939,120 @@ app.get('/foo', function (req, res, next) { app.get('/bar', function (req, res, next) { res.send('you viewed this page ' + req.session.views['/bar'] + ' times') }) + +app.listen(3000) +``` + +### User login + +A simple example using `express-session` to keep a user log in session. + +```js +var escapeHtml = require('escape-html') +var express = require('express') +var session = require('express-session') + +var app = express() + +app.use(session({ + secret: 'keyboard cat', + resave: false, + saveUninitialized: true +})) + +// middleware to test if authenticated +function isAuthenticated (req, res, next) { + if (req.session.user) next() + else next('route') +} + +app.get('/', isAuthenticated, function (req, res) { + // this is only called when there is an authentication user due to isAuthenticated + res.send('hello, ' + escapeHtml(req.session.user) + '!' + + ' Logout') +}) + +app.get('/', function (req, res) { + res.send('
' + + 'Username:
' + + 'Password:
' + + '
') +}) + +app.post('/login', express.urlencoded({ extended: false }), function (req, res) { + // login logic to validate req.body.user and req.body.pass + // would be implemented here. for this example any combo works + + // regenerate the session, which is good practice to help + // guard against forms of session fixation + req.session.regenerate(function (err) { + if (err) next(err) + + // store user information in session, typically a user id + req.session.user = req.body.user + + // save the session before redirection to ensure page + // load does not happen before session is saved + req.session.save(function (err) { + if (err) return next(err) + res.redirect('/') + }) + }) +}) + +app.get('/logout', function (req, res, next) { + // logout logic + + // clear the user from the session object and save. + // this will ensure that re-using the old session id + // does not have a logged in user + req.session.user = null + req.session.save(function (err) { + if (err) next(err) + + // regenerate the session, which is good practice to help + // guard against forms of session fixation + req.session.regenerate(function (err) { + if (err) next(err) + res.redirect('/') + }) + }) +}) + +app.listen(3000) +``` + +## Debugging + +This module uses the [debug](https://www.npmjs.com/package/debug) module +internally to log information about session operations. + +To see all the internal logs, set the `DEBUG` environment variable to +`express-session` when launching your app (`npm start`, in this example): + +```sh +$ DEBUG=express-session npm start +``` + +On Windows, use the corresponding command; + +```sh +> set DEBUG=express-session & npm start ``` ## License [MIT](LICENSE) -[npm-image]: https://img.shields.io/npm/v/express-session.svg?style=flat -[npm-url]: https://npmjs.org/package/express-session -[travis-image]: https://img.shields.io/travis/expressjs/session.svg?style=flat -[travis-url]: https://travis-ci.org/expressjs/session -[coveralls-image]: https://img.shields.io/coveralls/expressjs/session.svg?style=flat +[rfc-6265-5.1.4]: https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.4 +[rfc-6265bis-03-4.1.2.7]: https://tools.ietf.org/html/draft-ietf-httpbis-rfc6265bis-03#section-4.1.2.7 +[rfc-cutler-httpbis-partitioned-cookies]: https://tools.ietf.org/html/draft-cutler-httpbis-partitioned-cookies/ +[rfc-west-cookie-priority-00-4.1]: https://tools.ietf.org/html/draft-west-cookie-priority-00#section-4.1 +[ci-image]: https://badgen.net/github/checks/expressjs/session/master?label=ci +[ci-url]: https://github.com/expressjs/session/actions?query=workflow%3Aci +[coveralls-image]: https://badgen.net/coveralls/c/github/expressjs/session/master [coveralls-url]: https://coveralls.io/r/expressjs/session?branch=master -[downloads-image]: https://img.shields.io/npm/dm/express-session.svg?style=flat -[downloads-url]: https://npmjs.org/package/express-session -[gratipay-image]: https://img.shields.io/gratipay/dougwilson.svg?style=flat -[gratipay-url]: https://gratipay.com/dougwilson/ +[node-url]: https://nodejs.org/en/download +[npm-downloads-image]: https://badgen.net/npm/dm/express-session +[npm-url]: https://npmjs.org/package/express-session +[npm-version-image]: https://badgen.net/npm/v/express-session diff --git a/index.js b/index.js index 6f6d1b52..3e23e901 100644 --- a/index.js +++ b/index.js @@ -2,28 +2,31 @@ * express-session * Copyright(c) 2010 Sencha Inc. * Copyright(c) 2011 TJ Holowaychuk - * Copyright(c) 2014 Douglas Christopher Wilson + * Copyright(c) 2014-2015 Douglas Christopher Wilson * MIT Licensed */ +'use strict'; + /** * Module dependencies. * @private */ +var Buffer = require('safe-buffer').Buffer var cookie = require('cookie'); -var crc = require('crc').crc32; +var crypto = require('crypto') var debug = require('debug')('express-session'); var deprecate = require('depd')('express-session'); +var onHeaders = require('on-headers') var parseUrl = require('parseurl'); +var signature = require('cookie-signature') var uid = require('uid-safe').sync - , onHeaders = require('on-headers') - , signature = require('cookie-signature') +var Cookie = require('./session/cookie') +var MemoryStore = require('./session/memory') var Session = require('./session/session') - , MemoryStore = require('./session/memory') - , Cookie = require('./session/cookie') - , Store = require('./session/store') +var Store = require('./session/store') // environment @@ -67,7 +70,7 @@ var defer = typeof setImmediate === 'function' * Setup session store with the given `options`. * * @param {Object} [options] - * @param {Object} [options.cookie] Options for cookie + * @param {Object|Function} [options.cookie] Options for cookie * @param {Function} [options.genid] * @param {String} [options.name=connect.sid] Session ID cookie name * @param {Boolean} [options.proxy] @@ -81,20 +84,35 @@ var defer = typeof setImmediate === 'function' * @public */ -function session(options){ - var options = options || {} - // name - previously "options.key" - , name = options.name || options.key || 'connect.sid' - , store = options.store || new MemoryStore - , cookie = options.cookie || {} - , trustProxy = options.proxy - , storeReady = true - , rollingSessions = options.rolling || false; - var resaveSession = options.resave; - var saveUninitializedSession = options.saveUninitialized; - var secret = options.secret; - - var generateId = options.genid || generateSessionId; +function session(options) { + var opts = options || {} + + // get the cookie options + var cookieOptions = opts.cookie || {} + + // get the session id generate function + var generateId = opts.genid || generateSessionId + + // get the session cookie name + var name = opts.name || opts.key || 'connect.sid' + + // get the session store + var store = opts.store || new MemoryStore() + + // get the trust proxy setting + var trustProxy = opts.proxy + + // get the resave session option + var resaveSession = opts.resave; + + // get the rolling session option + var rollingSessions = Boolean(opts.rolling) + + // get the save uninitialized session option + var saveUninitializedSession = opts.saveUninitialized + + // get the cookie signing secret + var secret = opts.secret if (typeof generateId !== 'function') { throw new TypeError('genid option must be a function'); @@ -110,12 +128,12 @@ function session(options){ saveUninitializedSession = true; } - if (options.unset && options.unset !== 'destroy' && options.unset !== 'keep') { + if (opts.unset && opts.unset !== 'destroy' && opts.unset !== 'keep') { throw new TypeError('unset option must be "destroy" or "keep"'); } // TODO: switch to "destroy" on next major - var unsetDestroy = options.unset === 'destroy'; + var unsetDestroy = opts.unset === 'destroy' if (Array.isArray(secret) && secret.length === 0) { throw new TypeError('secret option array must contain one or more strings'); @@ -131,7 +149,8 @@ function session(options){ // notify user that this store is not // meant for a production environment - if ('production' == env && store instanceof MemoryStore) { + /* istanbul ignore next: not tested */ + if (env === 'production' && store instanceof MemoryStore) { console.warn(warning); } @@ -139,24 +158,57 @@ function session(options){ store.generate = function(req){ req.sessionID = generateId(req); req.session = new Session(req); - req.session.cookie = new Cookie(cookie); + var resolvedCookieOptions = typeof cookieOptions === 'function' ? cookieOptions(req) : cookieOptions; + req.session.cookie = new Cookie(resolvedCookieOptions); + + var isSecure = issecure(req, trustProxy); + + if (resolvedCookieOptions.secure === 'auto') { + req.session.cookie.secure = isSecure; + } + + if (resolvedCookieOptions.sameSite === 'auto') { + req.session.cookie.sameSite = isSecure ? 'none' : 'lax'; + } }; var storeImplementsTouch = typeof store.touch === 'function'; - store.on('disconnect', function(){ storeReady = false; }); - store.on('connect', function(){ storeReady = true; }); + + // register event listeners for the store to track readiness + var storeReady = true + store.on('disconnect', function ondisconnect() { + storeReady = false + }) + store.on('connect', function onconnect() { + storeReady = true + }) return function session(req, res, next) { // self-awareness - if (req.session) return next(); + if (req.session) { + next() + return + } // Handle connection as if there is no session if // the store has temporarily disconnected etc - if (!storeReady) return debug('store is disconnected'), next(); + if (!storeReady) { + debug('store is disconnected') + next() + return + } // pathname mismatch - var originalPath = parseUrl.original(req).pathname; - if (0 != originalPath.indexOf(cookie.path || '/')) return next(); + var originalPath = parseUrl.original(req).pathname || '/' + var resolvedCookieOptions = typeof cookieOptions === 'function' ? cookieOptions(req) : cookieOptions + var cfgPath = resolvedCookieOptions.path || '/' + + if (!rfcPathMatch(originalPath, cfgPath)) { + debug('pathname mismatch') + next() + return + } + // ensure a secret is available or bail if (!secret && !req.secret) { @@ -171,6 +223,7 @@ function session(options){ var originalHash; var originalId; var savedHash; + var touched = false // expose store req.sessionStore = store; @@ -185,19 +238,28 @@ function session(options){ return; } - var cookie = req.session.cookie; + if (!shouldSetCookie(req)) { + return; + } // only send secure cookies via https - if (cookie.secure && !issecure(req, trustProxy)) { + if (req.session.cookie.secure && !issecure(req, trustProxy)) { debug('not secured'); return; } - if (!shouldSetCookie(req)) { - return; + if (!touched) { + // touch session + req.session.touch() + touched = true } - setcookie(res, name, req.sessionID, secrets[0], cookie.data); + // set cookie + try { + setcookie(res, name, req.sessionID, secrets[0], req.session.cookie.data) + } catch (err) { + defer(next, err) + } }); // proxy end() to commit the session @@ -229,6 +291,10 @@ function session(options){ return ret; } + if (!res._header) { + res._implicitHeader() + } + if (chunk == null) { ret = true; return ret; @@ -239,7 +305,7 @@ function session(options){ if (!isNaN(contentLength) && contentLength > 0) { // measure chunk chunk = !Buffer.isBuffer(chunk) - ? new Buffer(chunk, encoding) + ? Buffer.from(chunk, encoding) : chunk; encoding = undefined; @@ -278,8 +344,11 @@ function session(options){ return _end.call(res, chunk, encoding); } - // touch session - req.session.touch(); + if (!touched) { + // touch session + req.session.touch() + touched = true + } if (shouldSave(req)) { req.session.save(function onsave(err) { @@ -317,16 +386,52 @@ function session(options){ wrapmethods(req.session); } + // inflate the session + function inflate (req, sess) { + store.createSession(req, sess) + originalId = req.sessionID + originalHash = hash(sess) + + if (!resaveSession) { + savedHash = originalHash + } + + wrapmethods(req.session) + } + + function rewrapmethods (sess, callback) { + return function () { + if (req.session !== sess) { + wrapmethods(req.session) + } + + callback.apply(this, arguments) + } + } + // wrap session methods function wrapmethods(sess) { + var _reload = sess.reload var _save = sess.save; + function reload(callback) { + debug('reloading %s', this.id) + _reload.call(this, rewrapmethods(this, callback)) + } + function save() { debug('saving %s', this.id); savedHash = hash(this); _save.apply(this, arguments); } + Object.defineProperty(sess, 'reload', { + configurable: true, + enumerable: false, + value: reload, + writable: true + }) + Object.defineProperty(sess, 'save', { configurable: true, enumerable: false, @@ -358,7 +463,7 @@ function session(options){ return false; } - return !saveUninitializedSession && cookieId !== req.sessionID + return !saveUninitializedSession && !savedHash && cookieId !== req.sessionID ? isModified(req.session) : !isSaved(req.session) } @@ -381,14 +486,9 @@ function session(options){ return false; } - // in case of rolling session, always reset the cookie - if (rollingSessions) { - return true; - } - - return cookieId != req.sessionID + return cookieId !== req.sessionID ? saveUninitializedSession || isModified(req.session) - : req.session.cookie.expires != null && isModified(req.session); + : rollingSessions || req.session.cookie.expires != null && isModified(req.session); } // generate a session if the browser doesn't send a sessionID @@ -403,38 +503,66 @@ function session(options){ debug('fetching %s', req.sessionID); store.get(req.sessionID, function(err, sess){ // error handling - if (err) { + if (err && err.code !== 'ENOENT') { debug('error %j', err); + next(err) + return + } - if (err.code !== 'ENOENT') { - next(err); - return; - } - - generate(); - // no session - } else if (!sess) { - debug('no session found'); - generate(); - // populate req.session - } else { - debug('session found'); - store.createSession(req, sess); - originalId = req.sessionID; - originalHash = hash(sess); - - if (!resaveSession) { - savedHash = originalHash + try { + if (err || !sess) { + debug('no session found') + generate() + } else { + debug('session found') + inflate(req, sess) } - - wrapmethods(req.session); + } catch (e) { + next(e) + return } - next(); + next() }); }; }; +/** + * Check if the cookiePath matches the requestPath following the + * rules in RFC 6265 section 5.1.4. + * + * @param {String} requestPath + * @param {String} cookiePath + * @return {Boolean} + * @private + */ + +function rfcPathMatch(requestPath, cookiePath) { + // Normalize inputs (Node 0.8-safe) + requestPath = (typeof requestPath === 'string' && requestPath.length) ? requestPath : '/'; + cookiePath = (typeof cookiePath === 'string' && cookiePath.length) ? cookiePath : '/'; + + // Root cookie matches everything + if (cookiePath === '/') return true; + + // Exact match + if (requestPath === cookiePath) return true; + + // Prefix match + if (requestPath.indexOf(cookiePath) === 0) { + // If cookiePath ends with '/', any longer requestPath is OK + if (cookiePath.charAt(cookiePath.length - 1) === '/') return true; + + // Otherwise the next char after the prefix must be '/' + var nextChar = requestPath.length > cookiePath.length + ? requestPath.charAt(cookiePath.length) + : ''; + return nextChar === '/'; + } + + return false; +} + /** * Generate a session ID for a new session. * @@ -442,7 +570,7 @@ function session(options){ * @private */ -function generateSessionId(sess) { +function generateSessionId() { return uid(24); } @@ -465,7 +593,7 @@ function getcookie(req, name, secrets) { raw = cookies[name]; if (raw) { - if (raw.substr(0, 2) === 's:') { + if (raw.slice(0, 2) === 's:') { val = unsigncookie(raw.slice(2), secrets); if (val === false) { @@ -492,7 +620,7 @@ function getcookie(req, name, secrets) { raw = req.cookies[name]; if (raw) { - if (raw.substr(0, 2) === 's:') { + if (raw.slice(0, 2) === 's:') { val = unsigncookie(raw.slice(2), secrets); if (val) { @@ -521,11 +649,21 @@ function getcookie(req, name, secrets) { */ function hash(sess) { - return crc(JSON.stringify(sess, function (key, val) { - if (key !== 'cookie') { - return val; + // serialize + var str = JSON.stringify(sess, function (key, val) { + // ignore sess.cookie property + if (this === sess && key === 'cookie') { + return } - })); + + return val + }) + + // hash + return crypto + .createHash('sha1') + .update(str, 'utf8') + .digest('hex') } /** @@ -539,7 +677,8 @@ function hash(sess) { function issecure(req, trustProxy) { // socket is https server - if (req.connection && req.connection.encrypted) { + var socket = req.socket || req.connection; + if (socket && socket.encrypted) { return true; } @@ -550,17 +689,14 @@ function issecure(req, trustProxy) { // no explicit trust; try req.secure from express if (trustProxy !== true) { - var secure = req.secure; - return typeof secure === 'boolean' - ? secure - : false; + return req.secure === true } // read the proto from x-forwarded-proto header var header = req.headers['x-forwarded-proto'] || ''; var index = header.indexOf(','); var proto = index !== -1 - ? header.substr(0, index).toLowerCase().trim() + ? header.slice(0, index).toLowerCase().trim() : header.toLowerCase().trim() return proto === 'https'; @@ -578,12 +714,10 @@ function setcookie(res, name, val, secret, options) { debug('set-cookie %s', data); - var prev = res.getHeader('set-cookie') || []; - var header = Array.isArray(prev) ? prev.concat(data) - : Array.isArray(data) ? [prev].concat(data) - : [prev, data]; + var prev = res.getHeader('Set-Cookie') || [] + var header = Array.isArray(prev) ? prev.concat(data) : [prev, data]; - res.setHeader('set-cookie', header) + res.setHeader('Set-Cookie', header) } /** diff --git a/package.json b/package.json index bf69317d..36387796 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "express-session", - "version": "1.11.1", + "version": "1.19.0", "description": "Simple session middleware for Express", "author": "TJ Holowaychuk (http://tjholowaychuk.com)", "contributors": [ @@ -8,38 +8,43 @@ "Joe Wagner " ], "repository": "expressjs/session", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + }, "license": "MIT", "dependencies": { - "cookie": "0.1.2", - "cookie-signature": "1.0.6", - "crc": "3.2.1", - "debug": "~2.1.3", - "depd": "~1.0.1", - "on-headers": "~1.0.0", - "parseurl": "~1.3.0", - "uid-safe": "1.1.0", - "utils-merge": "1.0.0" + "cookie": "~0.7.2", + "cookie-signature": "~1.0.7", + "debug": "~2.6.9", + "depd": "~2.0.0", + "on-headers": "~1.1.0", + "parseurl": "~1.3.3", + "safe-buffer": "~5.2.1", + "uid-safe": "~2.1.5" }, "devDependencies": { - "after": "0.8.1", - "cookie-parser": "~1.3.4", - "express": "~4.11.2", - "istanbul": "0.3.8", - "mocha": "~2.2.4", - "supertest": "~0.15.0" + "after": "0.8.2", + "cookie-parser": "1.4.6", + "eslint": "8.56.0", + "eslint-plugin-markdown": "3.0.1", + "express": "4.17.3", + "mocha": "10.8.2", + "nyc": "15.1.0", + "supertest": "6.3.4" }, "files": [ "session/", - "HISTORY.md", - "LICENSE", "index.js" ], "engines": { "node": ">= 0.8.0" }, "scripts": { - "test": "mocha --bail --reporter spec test/", - "test-cov": "istanbul cover node_modules/mocha/bin/_mocha -- --reporter dot test/", - "test-travis": "istanbul cover node_modules/mocha/bin/_mocha --report lcovonly -- --reporter spec test/" + "lint": "eslint . && node ./scripts/lint-readme.js", + "test": "./test/support/gencert.sh && mocha --require test/support/env --check-leaks --no-exit --reporter spec test/", + "test-ci": "nyc --reporter=lcov --reporter=text npm test", + "test-cov": "nyc npm test", + "version": "node scripts/version-history.js && git add HISTORY.md" } } diff --git a/scripts/lint-readme.js b/scripts/lint-readme.js new file mode 100644 index 00000000..f4c50d76 --- /dev/null +++ b/scripts/lint-readme.js @@ -0,0 +1,93 @@ +var assert = require('assert') +var fs = require('fs') +var path = require('path') + +var BADGE_STAR_LINK_REGEXP = /^https:\/\/badgen.net\/github\/stars\/[^/]+\/[^/]+?label=%E2%98%85$/ +var MARKDOWN_LINK_REGEXP = /^\[([^ \]]+)\]: ([^ ]+)$/ +var MARKDOWN_SECTION_REGEXP = /^#+ (.+)$/ +var NEWLINE_REGEXP = /\r?\n/ +var README_PATH = path.join(__dirname, '..', 'README.md') +var README_CONTENTS = fs.readFileSync(README_PATH, 'utf-8') +var STORE_HEADER_REGEXP = /^\[!\[★\]\[([^ \]]+)\] ([^ \]]+)\]\[([^ \]]+)\](?: .+)?$/ + +var header = null +var lintedStores = false +var section = null +var state = 0 + +README_CONTENTS.split(NEWLINE_REGEXP).forEach(function (line, lineidx) { + section = (MARKDOWN_SECTION_REGEXP.exec(line) || [])[1] || section + + if (section === 'Compatible Session Stores') { + lintedStores = true + + switch (state) { + case 0: // premble + if (line[0] !== '[') break + state = 1 + case 1: // header + var prev = header + + if (!(header = STORE_HEADER_REGEXP.exec(line))) { + expect(lineidx, 'session store header', line) + } else if (prev && prev[2].replace(/^[^/]+\//, '').localeCompare(header[2].replace(/^[^/]+\//, '')) > 0) { + expect(lineidx, (header[2] + ' to be alphabetically before ' + prev[2]), line) + state = 2 + } else { + state = 2 + } + + break + case 2: // blank line + if (line && MARKDOWN_LINK_REGEXP.test(line)) { + expect(lineidx, 'blank line or wrapped description', line) + } else if (line === '') { + state = 3 + } + break + case 3: // url link + var urlLink = MARKDOWN_LINK_REGEXP.exec(line) + + if (!urlLink) { + expect(lineidx, 'link reference', line) + } else if (urlLink[1] !== header[3]) { + expect(lineidx, ('link name of ' + header[3]), line) + } else if (urlLink[2] !== ('https://www.npmjs.com/package/' + header[2])) { + expect(lineidx, ('link url of https://www.npmjs.com/package/' + header[2]), line) + } else { + state = 4 + } + + break + case 4: // image link + var imageLink = MARKDOWN_LINK_REGEXP.exec(line) + + if (!imageLink) { + expect(lineidx, 'link reference', line) + } else if (imageLink[1] !== header[1]) { + expect(lineidx, ('link name of ' + header[1]), line) + } else if (!BADGE_STAR_LINK_REGEXP.test(imageLink[2])) { + expect(lineidx, ('link url to github stars badge'), line) + } else { + state = 5 + } + + break + case 5: // blank line + if (line !== '') { + expect(lineidx, 'blank line after links', line) + } else { + state = 1 + } + break + } + } +}) + +assert.ok(lintedStores, 'Compatible Session Stores section linted') + +function expect (lineidx, message, line) { + console.log('Expected %s on line %d', message, (lineidx + 1)) + console.log(' Got: %s', line) + process.exitCode = 1 +} diff --git a/scripts/version-history.js b/scripts/version-history.js new file mode 100644 index 00000000..c58268ad --- /dev/null +++ b/scripts/version-history.js @@ -0,0 +1,63 @@ +'use strict' + +var fs = require('fs') +var path = require('path') + +var HISTORY_FILE_PATH = path.join(__dirname, '..', 'HISTORY.md') +var MD_HEADER_REGEXP = /^====*$/ +var VERSION = process.env.npm_package_version +var VERSION_PLACEHOLDER_REGEXP = /^(?:unreleased|(\d+\.)+x)$/ + +var historyFileLines = fs.readFileSync(HISTORY_FILE_PATH, 'utf-8').split('\n') + +if (!MD_HEADER_REGEXP.test(historyFileLines[1])) { + console.error('Missing header in HISTORY.md') + process.exit(1) +} + +if (!VERSION_PLACEHOLDER_REGEXP.test(historyFileLines[0])) { + console.error('Missing placeholder version in HISTORY.md') + process.exit(1) +} + +if (historyFileLines[0].indexOf('x') !== -1) { + var versionCheckRegExp = new RegExp('^' + historyFileLines[0].replace('x', '.+') + '$') + + if (!versionCheckRegExp.test(VERSION)) { + console.error('Version %s does not match placeholder %s', VERSION, historyFileLines[0]) + process.exit(1) + } +} + +historyFileLines[0] = VERSION + ' / ' + getLocaleDate() +historyFileLines[1] = repeat('=', historyFileLines[0].length) + +fs.writeFileSync(HISTORY_FILE_PATH, historyFileLines.join('\n')) + +function getLocaleDate () { + var now = new Date() + + return zeroPad(now.getFullYear(), 4) + '-' + + zeroPad(now.getMonth() + 1, 2) + '-' + + zeroPad(now.getDate(), 2) +} + +function repeat (str, length) { + var out = '' + + for (var i = 0; i < length; i++) { + out += str + } + + return out +} + +function zeroPad (number, length) { + var num = number.toString() + + while (num.length < length) { + num = '0' + num + } + + return num +} diff --git a/session/cookie.js b/session/cookie.js index 86591de3..8bb5907b 100644 --- a/session/cookie.js +++ b/session/cookie.js @@ -1,4 +1,3 @@ - /*! * Connect - session - Cookie * Copyright(c) 2010 Sencha Inc. @@ -6,12 +5,14 @@ * MIT Licensed */ +'use strict'; + /** * Module dependencies. */ -var merge = require('utils-merge') - , cookie = require('cookie'); +var cookie = require('cookie') +var deprecate = require('depd')('express-session') /** * Initialize a new `Cookie` with the given `options`. @@ -25,10 +26,22 @@ var Cookie = module.exports = function Cookie(options) { this.path = '/'; this.maxAge = null; this.httpOnly = true; - if (options) merge(this, options); - this.originalMaxAge = undefined == this.originalMaxAge - ? this.maxAge - : this.originalMaxAge; + + if (options) { + if (typeof options !== 'object') { + throw new TypeError('argument options must be a object') + } + + for (var key in options) { + if (key !== 'data') { + this[key] = options[key] + } + } + } + + if (this.originalMaxAge === undefined || this.originalMaxAge === null) { + this.originalMaxAge = this.maxAge + } }; /*! @@ -68,7 +81,15 @@ Cookie.prototype = { */ set maxAge(ms) { - this.expires = 'number' == typeof ms + if (ms && typeof ms !== 'number' && !(ms instanceof Date)) { + throw new TypeError('maxAge must be a number or Date') + } + + if (ms instanceof Date) { + deprecate('maxAge as Date; pass number of milliseconds instead') + } + + this.expires = typeof ms === 'number' ? new Date(Date.now() + ms) : ms; }, @@ -95,12 +116,15 @@ Cookie.prototype = { get data() { return { - originalMaxAge: this.originalMaxAge + originalMaxAge: this.originalMaxAge, + partitioned: this.partitioned, + priority: this.priority , expires: this._expires , secure: this.secure , httpOnly: this.httpOnly , domain: this.domain , path: this.path + , sameSite: this.sameSite } }, diff --git a/session/memory.js b/session/memory.js index 9887680d..11ed686c 100644 --- a/session/memory.js +++ b/session/memory.js @@ -6,6 +6,8 @@ * MIT Licensed */ +'use strict'; + /** * Module dependencies. * @private @@ -114,6 +116,11 @@ MemoryStore.prototype.get = function get(sessionId, callback) { * @public */ +MemoryStore.prototype.set = function set(sessionId, session, callback) { + this.sessions[sessionId] = JSON.stringify(session) + callback && defer(callback) +} + /** * Get number of active sessions. * @@ -128,11 +135,6 @@ MemoryStore.prototype.length = function length(callback) { }) } -MemoryStore.prototype.set = function set(sessionId, session, callback) { - this.sessions[sessionId] = JSON.stringify(session) - callback && defer(callback) -} - /** * Touch the given session object associated with the given session ID. * @@ -169,14 +171,16 @@ function getSession(sessionId) { // parse sess = JSON.parse(sess) - var expires = typeof sess.cookie.expires === 'string' - ? new Date(sess.cookie.expires) - : sess.cookie.expires + if (sess.cookie) { + var expires = typeof sess.cookie.expires === 'string' + ? new Date(sess.cookie.expires) + : sess.cookie.expires - // destroy expired session - if (expires && expires <= Date.now()) { - delete this.sessions[sessionId] - return + // destroy expired session + if (expires && expires <= Date.now()) { + delete this.sessions[sessionId] + return + } } return sess diff --git a/session/session.js b/session/session.js index 4647f9da..fee7608c 100644 --- a/session/session.js +++ b/session/session.js @@ -1,4 +1,3 @@ - /*! * Connect - session - Session * Copyright(c) 2010 Sencha Inc. @@ -6,6 +5,8 @@ * MIT Licensed */ +'use strict'; + /** * Expose Session. */ @@ -43,9 +44,9 @@ function Session(req, data) { * @api public */ -Session.prototype.touch = function(){ +defineMethod(Session.prototype, 'touch', function touch() { return this.resetMaxAge(); -}; +}); /** * Reset `.maxAge` to `.originalMaxAge`. @@ -54,10 +55,10 @@ Session.prototype.touch = function(){ * @api public */ -Session.prototype.resetMaxAge = function(){ +defineMethod(Session.prototype, 'resetMaxAge', function resetMaxAge() { this.cookie.maxAge = this.cookie.originalMaxAge; return this; -}; +}); /** * Save the session data with optional callback `fn(err)`. @@ -67,10 +68,10 @@ Session.prototype.resetMaxAge = function(){ * @api public */ -Session.prototype.save = function(fn){ +defineMethod(Session.prototype, 'save', function save(fn) { this.req.sessionStore.set(this.id, this, fn || function(){}); return this; -}; +}); /** * Re-loads the session data _without_ altering @@ -84,9 +85,10 @@ Session.prototype.save = function(fn){ * @api public */ -Session.prototype.reload = function(fn){ +defineMethod(Session.prototype, 'reload', function reload(fn) { var req = this.req - , store = this.req.sessionStore; + var store = this.req.sessionStore + store.get(this.id, function(err, sess){ if (err) return fn(err); if (!sess) return fn(new Error('failed to load session')); @@ -94,7 +96,7 @@ Session.prototype.reload = function(fn){ fn(); }); return this; -}; +}); /** * Destroy `this` session. @@ -104,11 +106,11 @@ Session.prototype.reload = function(fn){ * @api public */ -Session.prototype.destroy = function(fn){ +defineMethod(Session.prototype, 'destroy', function destroy(fn) { delete this.req.session; this.req.sessionStore.destroy(this.id, fn); return this; -}; +}); /** * Regenerate this request's session. @@ -118,7 +120,24 @@ Session.prototype.destroy = function(fn){ * @api public */ -Session.prototype.regenerate = function(fn){ +defineMethod(Session.prototype, 'regenerate', function regenerate(fn) { this.req.sessionStore.regenerate(this.req, fn); return this; +}); + +/** + * Helper function for creating a method on a prototype. + * + * @param {Object} obj + * @param {String} name + * @param {Function} fn + * @private + */ +function defineMethod(obj, name, fn) { + Object.defineProperty(obj, name, { + configurable: true, + enumerable: false, + value: fn, + writable: true + }); }; diff --git a/session/store.js b/session/store.js index 54294cbd..3793877e 100644 --- a/session/store.js +++ b/session/store.js @@ -1,4 +1,3 @@ - /*! * Connect - session - Store * Copyright(c) 2010 Sencha Inc. @@ -6,27 +5,39 @@ * MIT Licensed */ +'use strict'; + /** * Module dependencies. + * @private */ +var Cookie = require('./cookie') var EventEmitter = require('events').EventEmitter - , Session = require('./session') - , Cookie = require('./cookie'); +var Session = require('./session') +var util = require('util') /** - * Initialize abstract `Store`. - * - * @api private + * Module exports. + * @public + */ + +module.exports = Store + +/** + * Abstract base class for session stores. + * @public */ -var Store = module.exports = function Store(options){}; +function Store () { + EventEmitter.call(this) +} /** - * Inherit from `EventEmitter.prototype`. + * Inherit from EventEmitter. */ -Store.prototype.__proto__ = EventEmitter.prototype; +util.inherits(Store, EventEmitter) /** * Re-generate the given requests's session. @@ -59,8 +70,7 @@ Store.prototype.load = function(sid, fn){ if (err) return fn(err); if (!sess) return fn(); var req = { sessionID: sid, sessionStore: self }; - sess = self.createSession(req, sess); - fn(null, sess); + fn(null, self.createSession(req, sess)) }); }; @@ -75,10 +85,18 @@ Store.prototype.load = function(sid, fn){ Store.prototype.createSession = function(req, sess){ var expires = sess.cookie.expires - , orig = sess.cookie.originalMaxAge; + var originalMaxAge = sess.cookie.originalMaxAge + sess.cookie = new Cookie(sess.cookie); - if ('string' == typeof expires) sess.cookie.expires = new Date(expires); - sess.cookie.originalMaxAge = orig; + + if (typeof expires === 'string') { + // convert expires to a Date object + sess.cookie.expires = new Date(expires) + } + + // keep originalMaxAge intact + sess.cookie.originalMaxAge = originalMaxAge + req.session = new Session(req, sess); return req.session; }; diff --git a/test/cookie.js b/test/cookie.js new file mode 100644 index 00000000..ea676e35 --- /dev/null +++ b/test/cookie.js @@ -0,0 +1,134 @@ + +var assert = require('assert') +var Cookie = require('../session/cookie') + +describe('new Cookie()', function () { + it('should create a new cookie object', function () { + assert.strictEqual(typeof new Cookie(), 'object') + }) + + it('should default expires to null', function () { + var cookie = new Cookie() + assert.strictEqual(cookie.expires, null) + }) + + it('should default httpOnly to true', function () { + var cookie = new Cookie() + assert.strictEqual(cookie.httpOnly, true) + }) + + it('should default path to "/"', function () { + var cookie = new Cookie() + assert.strictEqual(cookie.path, '/') + }) + + it('should default maxAge to null', function () { + var cookie = new Cookie() + assert.strictEqual(cookie.maxAge, null) + }) + + describe('with options', function () { + it('should create a new cookie object', function () { + assert.strictEqual(typeof new Cookie({}), 'object') + }) + + it('should reject non-objects', function () { + assert.throws(function () { new Cookie(42) }, /argument options/) + assert.throws(function () { new Cookie('foo') }, /argument options/) + assert.throws(function () { new Cookie(true) }, /argument options/) + assert.throws(function () { new Cookie(function () {}) }, /argument options/) + }) + + it('should ignore "data" option', function () { + var cookie = new Cookie({ data: { foo: 'bar' }, path: '/foo' }) + + assert.strictEqual(typeof cookie, 'object') + assert.strictEqual(typeof cookie.data, 'object') + assert.strictEqual(cookie.data.path, '/foo') + assert.notStrictEqual(cookie.data.foo, 'bar') + }) + + describe('expires', function () { + it('should set expires', function () { + var expires = new Date(Date.now() + 60000) + var cookie = new Cookie({ expires: expires }) + + assert.strictEqual(cookie.expires, expires) + }) + + it('should set maxAge', function () { + var expires = new Date(Date.now() + 60000) + var cookie = new Cookie({ expires: expires }) + + assert.ok(expires.getTime() - Date.now() - 1000 <= cookie.maxAge) + assert.ok(expires.getTime() - Date.now() + 1000 >= cookie.maxAge) + }) + }) + + describe('httpOnly', function () { + it('should set httpOnly', function () { + var cookie = new Cookie({ httpOnly: false }) + + assert.strictEqual(cookie.httpOnly, false) + }) + }) + + describe('maxAge', function () { + it('should set expires', function () { + var maxAge = 60000 + var cookie = new Cookie({ maxAge: maxAge }) + + assert.ok(cookie.expires.getTime() - Date.now() - 1000 <= maxAge) + assert.ok(cookie.expires.getTime() - Date.now() + 1000 >= maxAge) + }) + + it('should set maxAge', function () { + var maxAge = 60000 + var cookie = new Cookie({ maxAge: maxAge }) + + assert.strictEqual(typeof cookie.maxAge, 'number') + assert.ok(cookie.maxAge - 1000 <= maxAge) + assert.ok(cookie.maxAge + 1000 >= maxAge) + }) + + it('should accept Date object', function () { + var maxAge = new Date(Date.now() + 60000) + var cookie = new Cookie({ maxAge: maxAge }) + + assert.strictEqual(cookie.expires.getTime(), maxAge.getTime()) + assert.ok(maxAge.getTime() - Date.now() - 1000 <= cookie.maxAge) + assert.ok(maxAge.getTime() - Date.now() + 1000 >= cookie.maxAge) + }) + + it('should reject invalid types', function() { + assert.throws(function() { new Cookie({ maxAge: '42' }) }, /maxAge/) + assert.throws(function() { new Cookie({ maxAge: true }) }, /maxAge/) + assert.throws(function() { new Cookie({ maxAge: function () {} }) }, /maxAge/) + }) + }) + + describe('partitioned', function () { + it('should set partitioned', function () { + var cookie = new Cookie({ partitioned: true }) + + assert.strictEqual(cookie.partitioned, true) + }) + }) + + describe('path', function () { + it('should set path', function () { + var cookie = new Cookie({ path: '/foo' }) + + assert.strictEqual(cookie.path, '/foo') + }) + }) + + describe('priority', function () { + it('should set priority', function () { + var cookie = new Cookie({ priority: 'high' }) + + assert.strictEqual(cookie.priority, 'high') + }) + }) + }) +}) diff --git a/test/fixtures/.gitkeep b/test/fixtures/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/test/fixtures/server.crt b/test/fixtures/server.crt deleted file mode 100644 index c019198a..00000000 --- a/test/fixtures/server.crt +++ /dev/null @@ -1,14 +0,0 @@ ------BEGIN CERTIFICATE----- -MIICMzCCAZwCCQCJTms0qcIZgDANBgkqhkiG9w0BAQUFADBeMQswCQYDVQQGEwJV -UzEQMA4GA1UECBMHSW5kaWFuYTEdMBsGA1UEChMUbm9kZS1leHByZXNzLXNlc3Np -b24xHjAcBgNVBAMTFWV4cHJlc3Mtc2Vzc2lvbi5sb2NhbDAeFw0xNDExMjMwNTQ3 -MzlaFw0yNDExMjAwNTQ3MzlaMF4xCzAJBgNVBAYTAlVTMRAwDgYDVQQIEwdJbmRp -YW5hMR0wGwYDVQQKExRub2RlLWV4cHJlc3Mtc2Vzc2lvbjEeMBwGA1UEAxMVZXhw -cmVzcy1zZXNzaW9uLmxvY2FsMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDY -G398zqN6Yv/FAx77eoLLje4VNCYDXUciWBzceaZ5u/rtP/XshTQWDKFfUbb/kmni -DazsCasSoNsUCrcqQPJF1jF3F9XjsZxcggDab8MAbAMhnhJax2l3yPJsWnB/8mrY -cdsdQNPXJxQW9MMZgUxz73gIY5/rEeayU9owgcnVcQIDAQABMA0GCSqGSIb3DQEB -BQUAA4GBADi0XXsZlQAKxOyD4qvdJNWJXlfqhr1q53dZmfF7nikDaMDiMspczTS/ -pxNbq2UaMc7g+6qmXPaPQoN3laQQtMdyVfSh6EIJHbzXXzdcCT4fDBYX7iwvh0Gg -DUpjmxmCzFCaob9+hZzwbJi5MFQ4Qq12LW1aYHMs8wgLboHml1WL ------END CERTIFICATE----- diff --git a/test/fixtures/server.key b/test/fixtures/server.key deleted file mode 100644 index dabf2b83..00000000 --- a/test/fixtures/server.key +++ /dev/null @@ -1,15 +0,0 @@ ------BEGIN RSA PRIVATE KEY----- -MIICXQIBAAKBgQDYG398zqN6Yv/FAx77eoLLje4VNCYDXUciWBzceaZ5u/rtP/Xs -hTQWDKFfUbb/kmniDazsCasSoNsUCrcqQPJF1jF3F9XjsZxcggDab8MAbAMhnhJa -x2l3yPJsWnB/8mrYcdsdQNPXJxQW9MMZgUxz73gIY5/rEeayU9owgcnVcQIDAQAB -AoGBALB8HGw/kPA1Ay3Qc6/qCADWYvW8BcM/nQUmMkO3sUW/R5gTYPIMglHzdKIU -aL9kwcXTZ0HIT4ZCCUffzF/cdD0lqCZjwGl0aM4xUZcPbaM/KmZOUcP92ymsN+rF -uuJXks6hxrmJ5hh5D6FXdlQjTCdG3u9w0+KD4n1BkBkEaqflAkEA9q4TBt1bLOvV -bbwz7bunI6bwS5eD7sVn1qUqrm6hcirhF1xCv75pO/uqAyccbguxjp8y2LNuN+cO -IgSwr+Jq1wJBAOBFuKSMAcg2WmJ4IAT7143yhoHKMgA8Nl4sfLFwFl2/hw+fdEBe -gndRfKHT4IV/YLcnS7d/H6mEMSAusWx5QPcCQQDTYaF+TWrW2JRAf3jEK/xyiZf6 -PrDYh6KOhWRIqxZ/fYz69p1gL6t/sg0ivH4ZMr4JKBRrK360OrOapQg+/7drAkB6 -3pfPRok/aE/SfN+F+3fX49Q/TUhhipt6ssLJ74/BYsobDBADqAOwXSt7+XmbifKx -xUydRn9RPwQvDoXT2QZ3AkAdnwR9PMEHAsaibrPyzBztKjPL2rWBRk1QAmcdrTpt -XL99XfmkERWtiBA/Lz2K332qYOa/zo5c/SADx9fm7+HP ------END RSA PRIVATE KEY----- diff --git a/test/session.js b/test/session.js index 3289a425..405e3ddf 100644 --- a/test/session.js +++ b/test/session.js @@ -1,36 +1,38 @@ -process.env.NO_DEPRECATION = 'express-session'; - var after = require('after') var assert = require('assert') +var cookieParser = require('cookie-parser') +var crypto = require('crypto') var express = require('express') - , request = require('supertest') - , cookieParser = require('cookie-parser') - , session = require('../') - , Cookie = require('../session/cookie') var fs = require('fs') var http = require('http') var https = require('https') +var request = require('supertest') +var session = require('../') +var SmartStore = require('./support/smart-store') +var SyncStore = require('./support/sync-store') +var utils = require('./support/utils') + +var Cookie = require('../session/cookie') var min = 60 * 1000; describe('session()', function(){ it('should export constructors', function(){ - assert.equal(typeof session.Session, 'function') - assert.equal(typeof session.Store, 'function') - assert.equal(typeof session.MemoryStore, 'function') + assert.strictEqual(typeof session.Session, 'function') + assert.strictEqual(typeof session.Store, 'function') + assert.strictEqual(typeof session.MemoryStore, 'function') }) it('should do nothing if req.session exists', function(done){ - var app = express() - .use(function(req, res, next){ req.session = {}; next(); }) - .use(session({ secret: 'keyboard cat', cookie: { maxAge: min }})) - .use(end); + function setup (req) { + req.session = {} + } - request(app) - .get('/') - .expect(shouldNotHaveHeader('Set-Cookie')) - .expect(200, done) + request(createServer(setup)) + .get('/') + .expect(shouldNotHaveHeader('Set-Cookie')) + .expect(200, done) }) it('should error without secret', function(done){ @@ -40,13 +42,11 @@ describe('session()', function(){ }) it('should get secret from req.secret', function(done){ - var app = express() - .use(function(req, res, next){ req.secret = 'keyboard cat'; next(); }) - .use(session({ cookie: { maxAge: min }})) - .use(end); - app.set('env', 'test'); + function setup (req) { + req.secret = 'keyboard cat' + } - request(app) + request(createServer(setup, { secret: undefined })) .get('/') .expect(200, '', done) }) @@ -65,7 +65,7 @@ describe('session()', function(){ if (err) return done(err) store.length(function (err, len) { if (err) return done(err) - assert.equal(len, 1) + assert.strictEqual(len, 1) done() }) }) @@ -152,7 +152,7 @@ describe('session()', function(){ if (err) return done(err) store.all(function (err, sess) { if (err) return done(err) - assert.equal(Object.keys(sess).length, 2) + assert.strictEqual(Object.keys(sess).length, 2) done() }) } @@ -166,17 +166,25 @@ describe('session()', function(){ .expect(200, 'session created', cb) }) + it('should handle empty req.url', function (done) { + function setup (req) { + req.url = '' + } + + request(createServer(setup)) + .get('/') + .expect(shouldSetCookie('connect.sid')) + .expect(200, done) + }) + it('should handle multiple res.end calls', function(done){ - var app = express() - .use(session({ secret: 'keyboard cat', cookie: { maxAge: min }})) - .use(function(req, res){ - res.setHeader('Content-Type', 'text/plain'); - res.end('Hello, world!'); - res.end(); - }); - app.set('env', 'test'); + var server = createServer(null, function (req, res) { + res.setHeader('Content-Type', 'text/plain') + res.end('Hello, world!') + res.end() + }) - request(app) + request(server) .get('/') .expect('Content-Type', 'text/plain') .expect(200, 'Hello, world!', done); @@ -249,42 +257,55 @@ describe('session()', function(){ if (err) return done(err) store.length(function (err, length) { if (err) return done(err) - assert.equal(length, 0) + assert.strictEqual(length, 0) done() }) }) }) + it('should update cookie expiration when slow write', function (done) { + var server = createServer({ rolling: true }, function (req, res) { + req.session.user = 'bob' + res.write('hello, ') + setTimeout(function () { + res.end('world!') + }, 200) + }) + + request(server) + .get('/') + .expect(shouldSetCookie('connect.sid')) + .expect(200, function (err, res) { + if (err) return done(err); + var originalExpires = expires(res); + setTimeout(function () { + request(server) + .get('/') + .set('Cookie', cookie(res)) + .expect(shouldSetCookie('connect.sid')) + .expect(function (res) { assert.notStrictEqual(originalExpires, expires(res)); }) + .expect(200, done); + }, (1000 - (Date.now() % 1000) + 200)); + }); + }); + describe('when response ended', function () { it('should have saved session', function (done) { - var saved = false var store = new session.MemoryStore() var server = createServer({ store: store }, function (req, res) { req.session.hit = true res.end('session saved') }) - var _set = store.set - store.set = function set(sid, sess, callback) { - setTimeout(function () { - _set.call(store, sid, sess, function (err) { - saved = true - callback(err) - }) - }, 200) - } - request(server) - .get('/') - .expect(200, 'session saved', function (err) { - if (err) return done(err) - assert.ok(saved) - done() - }) + .get('/') + .expect(200) + .expect(shouldSetSessionInStore(store, 200)) + .expect('session saved') + .end(done) }) it('should have saved session even with empty response', function (done) { - var saved = false var store = new session.MemoryStore() var server = createServer({ store: store }, function (req, res) { req.session.hit = true @@ -292,27 +313,14 @@ describe('session()', function(){ res.end() }) - var _set = store.set - store.set = function set(sid, sess, callback) { - setTimeout(function () { - _set.call(store, sid, sess, function (err) { - saved = true - callback(err) - }) - }, 200) - } - request(server) - .get('/') - .expect(200, '', function (err) { - if (err) return done(err) - assert.ok(saved) - done() - }) + .get('/') + .expect(200) + .expect(shouldSetSessionInStore(store, 200)) + .end(done) }) it('should have saved session even with multi-write', function (done) { - var saved = false var store = new session.MemoryStore() var server = createServer({ store: store }, function (req, res) { req.session.hit = true @@ -321,27 +329,15 @@ describe('session()', function(){ res.end('world') }) - var _set = store.set - store.set = function set(sid, sess, callback) { - setTimeout(function () { - _set.call(store, sid, sess, function (err) { - saved = true - callback(err) - }) - }, 200) - } - request(server) - .get('/') - .expect(200, 'hello, world', function (err) { - if (err) return done(err) - assert.ok(saved) - done() - }) + .get('/') + .expect(200) + .expect(shouldSetSessionInStore(store, 200)) + .expect('hello, world') + .end(done) }) it('should have saved session even with non-chunked response', function (done) { - var saved = false var store = new session.MemoryStore() var server = createServer({ store: store }, function (req, res) { req.session.hit = true @@ -349,22 +345,48 @@ describe('session()', function(){ res.end('session saved') }) - var _set = store.set - store.set = function set(sid, sess, callback) { - setTimeout(function () { - _set.call(store, sid, sess, function (err) { - saved = true - callback(err) - }) - }, 200) - } + request(server) + .get('/') + .expect(200) + .expect(shouldSetSessionInStore(store, 200)) + .expect('session saved') + .end(done) + }) + + it('should have saved session with updated cookie expiration', function (done) { + var store = new session.MemoryStore() + var server = createServer({ cookie: { maxAge: min }, store: store }, function (req, res) { + req.session.user = 'bob' + res.end(req.session.id) + }) request(server) .get('/') - .expect(200, 'session saved', function (err) { + .expect(shouldSetCookie('connect.sid')) + .expect(200, function (err, res) { if (err) return done(err) - assert.ok(saved) - done() + var id = res.text + store.get(id, function (err, sess) { + if (err) return done(err) + assert.ok(sess, 'session saved to store') + var exp = new Date(sess.cookie.expires) + assert.strictEqual(exp.toUTCString(), expires(res)) + setTimeout(function () { + request(server) + .get('/') + .set('Cookie', cookie(res)) + .expect(200, function (err, res) { + if (err) return done(err) + store.get(id, function (err, sess) { + if (err) return done(err) + assert.strictEqual(res.text, id) + assert.ok(sess, 'session still in store') + assert.notStrictEqual(new Date(sess.cookie.expires).toUTCString(), exp.toUTCString(), 'session cookie expiration updated') + done() + }) + }) + }, (1000 - (Date.now() % 1000) + 200)) + }) }) }) }) @@ -406,19 +428,14 @@ describe('session()', function(){ .expect(shouldSetCookie('connect.sid')) .expect(200, 'session 1', function (err, res) { if (err) return done(err) - var val = sid(res) - assert.ok(val) store.clear(function (err) { if (err) return done(err) request(server) .get('/') .set('Cookie', cookie(res)) .expect(shouldSetCookie('connect.sid')) - .expect(200, 'session 2', function (err, res) { - if (err) return done(err) - assert.notEqual(sid(res), val) - done() - }) + .expect(shouldSetCookieToDifferentSessionId(sid(res))) + .expect(200, 'session 2', done) }) }) }) @@ -444,6 +461,7 @@ describe('session()', function(){ .get('/') .set('Cookie', 'sessid=' + val) .expect(shouldSetCookie('sessid')) + .expect(shouldSetCookieToDifferentSessionId(val)) .expect(200, 'session created', done) }) }) @@ -510,18 +528,13 @@ describe('session()', function(){ .expect(shouldSetCookie('connect.sid')) .expect(200, 'session 1', function (err, res) { if (err) return done(err) - var val = sid(res) - assert.ok(val) setTimeout(function () { request(server) .get('/') .set('Cookie', cookie(res)) .expect(shouldSetCookie('connect.sid')) - .expect(200, 'session 2', function (err, res) { - if (err) return done(err) - assert.notEqual(sid(res), val) - done() - }) + .expect(shouldSetCookieToDifferentSessionId(sid(res))) + .expect(200, 'session 2', done) }, 15) }) }) @@ -542,7 +555,7 @@ describe('session()', function(){ setTimeout(function () { store.all(function (err, sess) { if (err) return done(err) - assert.equal(Object.keys(sess).length, 0) + assert.strictEqual(Object.keys(sess).length, 0) done() }) }, 10) @@ -550,6 +563,31 @@ describe('session()', function(){ }) }) + describe('when session without cookie property in store', function () { + it('should pass error from inflate', function (done) { + var count = 0 + var store = new session.MemoryStore() + var server = createServer({ store: store }, function (req, res) { + req.session.num = req.session.num || ++count + res.end('session ' + req.session.num) + }) + + request(server) + .get('/') + .expect(shouldSetCookie('connect.sid')) + .expect(200, 'session 1', function (err, res) { + if (err) return done(err) + store.set(sid(res), { foo: 'bar' }, function (err) { + if (err) return done(err) + request(server) + .get('/') + .set('Cookie', cookie(res)) + .expect(500, /Cannot read prop/, done) + }) + }) + }) + }) + describe('proxy option', function(){ describe('when enabled', function(){ var server @@ -582,391 +620,816 @@ describe('session()', function(){ }) describe('when disabled', function(){ - var server before(function () { - server = createServer({ proxy: false, cookie: { secure: true, maxAge: 5 }}) + function setup (req) { + req.secure = req.headers['x-secure'] + ? JSON.parse(req.headers['x-secure']) + : undefined + } + + function respond (req, res) { + res.end(String(req.secure)) + } + + this.server = createServer(setup, { proxy: false, cookie: { secure: true }}, respond) }) it('should not trust X-Forwarded-Proto', function(done){ - request(server) + request(this.server) .get('/') .set('X-Forwarded-Proto', 'https') .expect(shouldNotHaveHeader('Set-Cookie')) .expect(200, done) }) - it('should ignore req.secure from express', function(done){ - var app = express() - .use(session({ secret: 'keyboard cat', proxy: false, cookie: { secure: true, maxAge: min }})) - .use(function(req, res) { res.json(req.secure); }); - app.enable('trust proxy'); - - request(app) + it('should ignore req.secure', function (done) { + request(this.server) .get('/') .set('X-Forwarded-Proto', 'https') + .set('X-Secure', 'true') .expect(shouldNotHaveHeader('Set-Cookie')) .expect(200, 'true', done) }) }) describe('when unspecified', function(){ - var server before(function () { - server = createServer({ cookie: { secure: true, maxAge: 5 }}) + function setup (req) { + req.secure = req.headers['x-secure'] + ? JSON.parse(req.headers['x-secure']) + : undefined + } + + function respond (req, res) { + res.end(String(req.secure)) + } + + this.server = createServer(setup, { cookie: { secure: true }}, respond) }) it('should not trust X-Forwarded-Proto', function(done){ - request(server) + request(this.server) .get('/') .set('X-Forwarded-Proto', 'https') .expect(shouldNotHaveHeader('Set-Cookie')) .expect(200, done) }) - it('should use req.secure from express', function(done){ - var app = express() - .use(session({ secret: 'keyboard cat', cookie: { secure: true, maxAge: min }})) - .use(function(req, res) { res.json(req.secure); }); - app.enable('trust proxy'); - - request(app) + it('should use req.secure', function (done) { + request(this.server) .get('/') .set('X-Forwarded-Proto', 'https') + .set('X-Secure', 'true') .expect(shouldSetCookie('connect.sid')) .expect(200, 'true', done) }) }) }) - describe('genid option', function(){ - it('should reject non-function values', function(){ - assert.throws(session.bind(null, { genid: 'bogus!' }), /genid.*must/) - }); - - it('should provide default generator', function(done){ - request(createServer()) - .get('/') - .expect(shouldSetCookie('connect.sid')) - .expect(200, done) - }); + describe('cookie option', function () { + describe('when "path" set to "/foo/bar"', function () { + before(function () { + this.server = createServer({ cookie: { path: '/foo/bar' } }) + }) - it('should allow custom function', function(done){ - function genid() { return 'apple' } + it('should not set cookie for "/" request', function (done) { + request(this.server) + .get('/') + .expect(shouldNotHaveHeader('Set-Cookie')) + .expect(200, done) + }) - request(createServer({ genid: genid })) - .get('/') - .expect(shouldSetCookieToValue('connect.sid', 's%3Aapple.D8Y%2BpkTAmeR0PobOhY4G97PRW%2Bj7bUnP%2F5m6%2FOn1MCU')) - .expect(200, done) - }); + it('should not set cookie for "http://foo/bar" request', function (done) { + request(this.server) + .get('/') + .set('host', 'http://foo/bar') + .expect(shouldNotHaveHeader('Set-Cookie')) + .expect(200, done) + }) - it('should encode unsafe chars', function(done){ - function genid() { return '%' } + it('should set cookie for "/foo/bar" request', function (done) { + request(this.server) + .get('/foo/bar/baz') + .expect(shouldSetCookie('connect.sid')) + .expect(200, done) + }) - request(createServer({ genid: genid })) - .get('/') - .expect(shouldSetCookieToValue('connect.sid', 's%3A%25.kzQ6x52kKVdF35Qh62AWk4ZekS28K5XYCXKa%2FOTZ01g')) - .expect(200, done) - }); + it('should set cookie for "/foo/bar/baz" request', function (done) { + request(this.server) + .get('/foo/bar/baz') + .expect(shouldSetCookie('connect.sid')) + .expect(200, done) + }) - it('should provide req argument', function(done){ - function genid(req) { return req.url } + describe('when mounted at "/foo"', function () { + before(function () { + this.server = createServer(mountAt('/foo'), { cookie: { path: '/foo/bar' } }) + }) - request(createServer({ genid: genid })) - .get('/foo') - .expect(shouldSetCookieToValue('connect.sid', 's%3A%2Ffoo.paEKBtAHbV5s1IB8B2zPnzAgYmmnRPIqObW4VRYj%2FMQ')) - .expect(200, done) - }); - }); + it('should set cookie for "/foo/bar" request', function (done) { + request(this.server) + .get('/foo/bar') + .expect(shouldSetCookie('connect.sid')) + .expect(200, done) + }) - describe('key option', function(){ - it('should default to "connect.sid"', function(done){ - request(createServer()) - .get('/') - .expect(shouldSetCookie('connect.sid')) - .expect(200, done) + it('should not set cookie for "/foo/foo/bar" request', function (done) { + request(this.server) + .get('/foo/foo/bar') + .expect(shouldNotHaveHeader('Set-Cookie')) + .expect(200, done) + }) + }) }) - it('should allow overriding', function(done){ - request(createServer({ key: 'session_id' })) - .get('/') - .expect(shouldSetCookie('session_id')) - .expect(200, done) - }) - }) + describe('when "secure" set to "auto"', function () { + describe('when "proxy" is "true"', function () { + before(function () { + this.server = createServer({ proxy: true, cookie: { maxAge: 5, secure: 'auto' }}) + }) - describe('rolling option', function(){ - it('should default to false', function(done){ - var app = express(); - app.use(session({ secret: 'keyboard cat', cookie: { maxAge: min }})); - app.use(function(req, res, next){ - var save = req.session.save; - req.session.user = 'bob'; - res.end(); - }); + it('should set secure when X-Forwarded-Proto is https', function (done) { + request(this.server) + .get('/') + .set('X-Forwarded-Proto', 'https') + .expect(shouldSetCookieWithAttribute('connect.sid', 'Secure')) + .expect(200, done) + }) + }) - request(app) - .get('/') - .expect(shouldSetCookie('connect.sid')) - .expect(200, function(err, res){ - if (err) return done(err); - request(app) - .get('/') - .set('Cookie', cookie(res)) - .expect(shouldNotHaveHeader('Set-Cookie')) - .expect(200, done) - }); - }); + describe('when "proxy" is "false"', function () { + before(function () { + this.server = createServer({ proxy: false, cookie: { maxAge: 5, secure: 'auto' }}) + }) - it('should force cookie on unmodified session', function(done){ - var app = express(); - app.use(session({ rolling: true, secret: 'keyboard cat', cookie: { maxAge: min }})); - app.use(function(req, res, next){ - var save = req.session.save; - req.session.user = 'bob'; - res.end(); - }); + it('should not set secure when X-Forwarded-Proto is https', function (done) { + request(this.server) + .get('/') + .set('X-Forwarded-Proto', 'https') + .expect(shouldSetCookieWithoutAttribute('connect.sid', 'Secure')) + .expect(200, done) + }) + }) - request(app) - .get('/') - .expect(shouldSetCookie('connect.sid')) - .expect(200, function(err, res){ - if (err) return done(err); - request(app) - .get('/') - .set('Cookie', cookie(res)) - .expect(shouldSetCookie('connect.sid')) - .expect(200, done) - }); - }); - }); + describe('when "proxy" is undefined', function() { + before(function () { + function setup (req) { + req.secure = JSON.parse(req.headers['x-secure']) + } - describe('resave option', function(){ - it('should default to true', function(done){ - var count = 0; - var app = express(); - app.use(session({ secret: 'keyboard cat', cookie: { maxAge: min }})); - app.use(function(req, res, next){ - var save = req.session.save; - res.setHeader('x-count', count); - req.session.user = 'bob'; - req.session.save = function(fn){ - res.setHeader('x-count', ++count); - return save.call(this, fn); - }; - res.end(); - }); + function respond (req, res) { + res.end(String(req.secure)) + } - request(app) - .get('/') - .expect('x-count', '1') - .expect(200, function(err, res){ - if (err) return done(err); - request(app) - .get('/') - .set('Cookie', cookie(res)) - .expect('x-count', '2') - .expect(200, done); + this.server = createServer(setup, { cookie: { secure: 'auto' } }, respond) + }) + + it('should set secure if req.secure = true', function (done) { + request(this.server) + .get('/') + .set('X-Secure', 'true') + .expect(shouldSetCookieWithAttribute('connect.sid', 'Secure')) + .expect(200, 'true', done) + }) + + it('should not set secure if req.secure = false', function (done) { + request(this.server) + .get('/') + .set('X-Secure', 'false') + .expect(shouldSetCookieWithoutAttribute('connect.sid', 'Secure')) + .expect(200, 'false', done) + }) + }) + + describe('when request socket is encrypted', function () { + it('should set secure over TLS', function (done) { + var cert = fs.readFileSync(__dirname + '/fixtures/server.crt', 'ascii') + var server = https.createServer({ + key: fs.readFileSync(__dirname + '/fixtures/server.key', 'ascii'), + cert: cert + }) + + server.on('request', createRequestListener({ secret: 'keyboard cat', cookie: { secure: 'auto' } })) + + var agent = new https.Agent({ ca: cert }) + var createConnection = agent.createConnection + + agent.createConnection = function (options) { + options.servername = 'express-session.local' + return createConnection.call(this, options) + } + + var req = request(server).get('/') + req.agent(agent) + req.expect(shouldSetCookieWithAttribute('connect.sid', 'Secure')) + req.expect(200, done) + }) + }) + }) + + describe('when "cookie" is a function', function () { + it('should call custom function and apply cookie options', function (done) { + var cookieCallbackCalled = false; + var cookieCallback = function () { + cookieCallbackCalled = true; + return { path: '/test', httpOnly: true, secure: false }; + }; + var server = createServer({ cookie: cookieCallback }); + request(server) + .get('/test') + .expect( + shouldSetCookieWithAttributeAndValue('connect.sid', 'Path', '/test') + ) + .expect(shouldSetCookieWithAttribute('connect.sid', 'HttpOnly')) + .expect(shouldSetCookieWithoutAttribute('connect.sid', 'Secure')) + .expect(200, function (err) { + if (err) return done(err); + assert.strictEqual( + cookieCallbackCalled, + true, + 'should have called cookie callback' + ); + done(); + }); + }); + + it('should provide req argument', function (done) { + var _path = '/test'; + var cookieCallbackCalled = false; + var cookieCallback = function (req) { + cookieCallbackCalled = true; + return { path: req.url, httpOnly: true, secure: false }; + }; + var server = createServer({ cookie: cookieCallback }); + request(server) + .get(_path) + .expect( + shouldSetCookieWithAttributeAndValue('connect.sid', 'Path', _path) + ) + .expect(shouldSetCookieWithAttribute('connect.sid', 'HttpOnly')) + .expect(shouldSetCookieWithoutAttribute('connect.sid', 'Secure')) + .expect(200, function (err) { + if (err) return done(err); + assert.strictEqual( + cookieCallbackCalled, + true, + 'should have called cookie callback' + ); + done(); + }); }); - }); - it('should force save on unmodified session', function(done){ - var count = 0; - var app = express(); - app.use(session({ resave: true, secret: 'keyboard cat', cookie: { maxAge: min }})); - app.use(function(req, res, next){ - var save = req.session.save; - res.setHeader('x-count', count); - req.session.user = 'bob'; - req.session.save = function(fn){ - res.setHeader('x-count', ++count); - return save.call(this, fn); + it('should not set cookie when request-path does not match returned path', function (done) { + var cookieCallback = function () { + return { path: '/admin', httpOnly: true, secure: false }; }; - res.end(); + var server = createServer({ cookie: cookieCallback }); + request(server) + .get('/administrator') + .expect(shouldNotHaveHeader('Set-Cookie')) + .expect(200, done); }); - request(app) + it('should resolve "secure" and "sameSite" set to "auto"', function (done) { + function setup (req) { + req.secure = JSON.parse(req.headers['x-secure']) + } + + function respond (req, res) { + res.end(String(req.secure)) + } + + var cookieCallback = function () { + return { path: '/', secure: 'auto', sameSite: 'auto' }; + }; + var server = createServer(setup, { cookie: cookieCallback }, respond); + request(server) + .get('/') + .set('X-Secure', 'true') + .expect(shouldSetCookieWithAttribute('connect.sid', 'Secure')) + .expect( + shouldSetCookieWithAttributeAndValue('connect.sid', 'SameSite', 'None') + ) + .expect(200, 'true', done); + }); + }); + + + describe('when "sameSite" set to "auto"', function () { + describe('basic functionality', function () { + before(function () { + function setup (req) { + req.secure = JSON.parse(req.headers['x-secure']) + } + + function respond (req, res) { + res.end(String(req.secure)) + } + + this.server = createServer(setup, { cookie: { sameSite: 'auto' } }, respond) + }) + + it('should set SameSite=None for secure connections', function (done) { + request(this.server) + .get('/') + .set('X-Secure', 'true') + .expect(shouldSetCookieWithAttributeAndValue('connect.sid', 'SameSite', 'None')) + .expect(200, 'true', done) + }) + + it('should set SameSite=Lax for insecure connections', function (done) { + request(this.server) + .get('/') + .set('X-Secure', 'false') + .expect(shouldSetCookieWithAttributeAndValue('connect.sid', 'SameSite', 'Lax')) + .expect(200, 'false', done) + }) + }) + + describe('with proxy settings', function () { + describe('when "proxy" is "true"', function () { + before(function () { + this.server = createServer({ proxy: true, cookie: { sameSite: 'auto' }}) + }) + + it('should set SameSite=None when X-Forwarded-Proto is https', function (done) { + request(this.server) + .get('/') + .set('X-Forwarded-Proto', 'https') + .expect(shouldSetCookieWithAttributeAndValue('connect.sid', 'SameSite', 'None')) + .expect(200, done) + }) + + it('should set SameSite=Lax when X-Forwarded-Proto is http', function (done) { + request(this.server) + .get('/') + .set('X-Forwarded-Proto', 'http') + .expect(shouldSetCookieWithAttributeAndValue('connect.sid', 'SameSite', 'Lax')) + .expect(200, done) + }) + }) + + describe('when "proxy" is "false"', function () { + before(function () { + this.server = createServer({ proxy: false, cookie: { sameSite: 'auto' }}) + }) + + it('should set SameSite=Lax when X-Forwarded-Proto is https', function (done) { + request(this.server) + .get('/') + .set('X-Forwarded-Proto', 'https') + .expect(shouldSetCookieWithAttributeAndValue('connect.sid', 'SameSite', 'Lax')) + .expect(200, done) + }) + }) + }) + + describe('combined with secure auto', function() { + describe('when "secure" is "auto"', function () { + before(function () { + function setup (req) { + req.secure = JSON.parse(req.headers['x-secure']) + } + + function respond (req, res) { + res.end(String(req.secure)) + } + + this.server = createServer(setup, { cookie: { secure: 'auto', sameSite: 'auto' } }, respond) + }) + + it('should set both Secure and SameSite=None when secure', function (done) { + request(this.server) + .get('/') + .set('X-Secure', 'true') + .expect(shouldSetCookieWithAttribute('connect.sid', 'Secure')) + .expect(shouldSetCookieWithAttributeAndValue('connect.sid', 'SameSite', 'None')) + .expect(200, 'true', done) + }) + + it('should set neither Secure nor SameSite=None when insecure', function (done) { + request(this.server) + .get('/') + .set('X-Secure', 'false') + .expect(shouldSetCookieWithoutAttribute('connect.sid', 'Secure')) + .expect(shouldSetCookieWithAttributeAndValue('connect.sid', 'SameSite', 'Lax')) + .expect(200, 'false', done) + }) + }) + + describe('when "secure" is "false"', function () { + before(function () { + function setup (req) { + req.secure = JSON.parse(req.headers['x-secure']) + } + + function respond (req, res) { + res.end(String(req.secure)) + } + + this.server = createServer(setup, { cookie: { secure: false, sameSite: 'auto' } }, respond) + }) + + it('should set SameSite=None without Secure when secure', function (done) { + request(this.server) + .get('/') + .set('X-Secure', 'true') + .expect(shouldSetCookieWithoutAttribute('connect.sid', 'Secure')) + .expect(shouldSetCookieWithAttributeAndValue('connect.sid', 'SameSite', 'None')) + .expect(200, 'true', done) + }) + + it('should set SameSite=Lax without Secure when insecure', function (done) { + request(this.server) + .get('/') + .set('X-Secure', 'false') + .expect(shouldSetCookieWithoutAttribute('connect.sid', 'Secure')) + .expect(shouldSetCookieWithAttributeAndValue('connect.sid', 'SameSite', 'Lax')) + .expect(200, 'false', done) + }) + }) + + describe('when "secure" is "true"', function () { + before(function () { + function setup (req) { + req.secure = JSON.parse(req.headers['x-secure']) + } + + function respond (req, res) { + res.end(String(req.secure)) + } + + this.server = createServer(setup, { cookie: { secure: true, sameSite: 'auto' } }, respond) + }) + + it('should set both Secure and SameSite=None when secure', function (done) { + request(this.server) + .get('/') + .set('X-Secure', 'true') + .expect(shouldSetCookieWithAttribute('connect.sid', 'Secure')) + .expect(shouldSetCookieWithAttributeAndValue('connect.sid', 'SameSite', 'None')) + .expect(200, 'true', done) + }) + + it('should not set cookie when insecure', function (done) { + request(this.server) + .get('/') + .set('X-Secure', 'false') + .expect(shouldNotHaveHeader('Set-Cookie')) + .expect(200, 'false', done) + }) + }) + }) + }) + }) + + describe('genid option', function(){ + it('should reject non-function values', function(){ + assert.throws(session.bind(null, { genid: 'bogus!' }), /genid.*must/) + }); + + it('should provide default generator', function(done){ + request(createServer()) + .get('/') + .expect(shouldSetCookie('connect.sid')) + .expect(200, done) + }); + + it('should allow custom function', function(done){ + function genid() { return 'apple' } + + request(createServer({ genid: genid })) + .get('/') + .expect(shouldSetCookieToValue('connect.sid', 's%3Aapple.D8Y%2BpkTAmeR0PobOhY4G97PRW%2Bj7bUnP%2F5m6%2FOn1MCU')) + .expect(200, done) + }); + + it('should encode unsafe chars', function(done){ + function genid() { return '%' } + + request(createServer({ genid: genid })) + .get('/') + .expect(shouldSetCookieToValue('connect.sid', 's%3A%25.kzQ6x52kKVdF35Qh62AWk4ZekS28K5XYCXKa%2FOTZ01g')) + .expect(200, done) + }); + + it('should provide req argument', function(done){ + function genid(req) { return req.url } + + request(createServer({ genid: genid })) + .get('/foo') + .expect(shouldSetCookieToValue('connect.sid', 's%3A%2Ffoo.paEKBtAHbV5s1IB8B2zPnzAgYmmnRPIqObW4VRYj%2FMQ')) + .expect(200, done) + }); + }); + + describe('key option', function(){ + it('should default to "connect.sid"', function(done){ + request(createServer()) + .get('/') + .expect(shouldSetCookie('connect.sid')) + .expect(200, done) + }) + + it('should allow overriding', function(done){ + request(createServer({ key: 'session_id' })) + .get('/') + .expect(shouldSetCookie('session_id')) + .expect(200, done) + }) + }) + + describe('name option', function () { + it('should default to "connect.sid"', function (done) { + request(createServer()) + .get('/') + .expect(shouldSetCookie('connect.sid')) + .expect(200, done) + }) + + it('should set the cookie name', function (done) { + request(createServer({ name: 'session_id' })) .get('/') - .expect('x-count', '1') + .expect(shouldSetCookie('session_id')) + .expect(200, done) + }) + }) + + describe('rolling option', function(){ + it('should default to false', function(done){ + var server = createServer(null, function (req, res) { + req.session.user = 'bob' + res.end() + }) + + request(server) + .get('/') + .expect(shouldSetCookie('connect.sid')) .expect(200, function(err, res){ if (err) return done(err); - request(app) + request(server) .get('/') .set('Cookie', cookie(res)) - .expect('x-count', '2') - .expect(200, done); + .expect(shouldNotHaveHeader('Set-Cookie')) + .expect(200, done) }); }); - it('should prevent save on unmodified session', function(done){ - var count = 0; - var app = express(); - app.use(session({ resave: false, secret: 'keyboard cat', cookie: { maxAge: min }})); - app.use(function(req, res, next){ - var save = req.session.save; - res.setHeader('x-count', count); - req.session.user = 'bob'; - req.session.save = function(fn){ - res.setHeader('x-count', ++count); - return save.call(this, fn); - }; - res.end(); - }); + it('should force cookie on unmodified session', function(done){ + var server = createServer({ rolling: true }, function (req, res) { + req.session.user = 'bob' + res.end() + }) - request(app) + request(server) .get('/') - .expect('x-count', '1') + .expect(shouldSetCookie('connect.sid')) .expect(200, function(err, res){ if (err) return done(err); - request(app) + request(server) .get('/') .set('Cookie', cookie(res)) - .expect('x-count', '1') - .expect(200, done); + .expect(shouldSetCookie('connect.sid')) + .expect(200, done) }); }); - it('should still save modified session', function(done){ - var count = 0; - var app = express(); - app.use(session({ resave: false, secret: 'keyboard cat', cookie: { maxAge: min }})); - app.use(function(req, res, next){ - var save = req.session.save; - res.setHeader('x-count', count); - req.session.count = count; - req.session.user = 'bob'; - req.session.save = function(fn){ - res.setHeader('x-count', ++count); - return save.call(this, fn); - }; - res.end(); - }); + it('should not force cookie on uninitialized session if saveUninitialized option is set to false', function(done){ + var store = new session.MemoryStore() + var server = createServer({ store: store, rolling: true, saveUninitialized: false }) - request(app) + request(server) + .get('/') + .expect(shouldNotSetSessionInStore(store)) + .expect(shouldNotHaveHeader('Set-Cookie')) + .expect(200, done) + }); + + it('should force cookie and save uninitialized session if saveUninitialized option is set to true', function(done){ + var store = new session.MemoryStore() + var server = createServer({ store: store, rolling: true, saveUninitialized: true }) + + request(server) .get('/') - .expect('x-count', '1') + .expect(shouldSetSessionInStore(store)) + .expect(shouldSetCookie('connect.sid')) + .expect(200, done) + }); + + it('should force cookie and save modified session even if saveUninitialized option is set to false', function(done){ + var store = new session.MemoryStore() + var server = createServer({ store: store, rolling: true, saveUninitialized: false }, function (req, res) { + req.session.user = 'bob' + res.end() + }) + + request(server) + .get('/') + .expect(shouldSetSessionInStore(store)) + .expect(shouldSetCookie('connect.sid')) + .expect(200, done); + }); + }); + + describe('resave option', function(){ + it('should default to true', function(done){ + var store = new session.MemoryStore() + var server = createServer({ store: store }, function (req, res) { + req.session.user = 'bob' + res.end() + }) + + request(server) + .get('/') + .expect(shouldSetSessionInStore(store)) .expect(200, function(err, res){ if (err) return done(err); - request(app) + request(server) .get('/') .set('Cookie', cookie(res)) - .expect('x-count', '2') + .expect(shouldSetSessionInStore(store)) .expect(200, done); }); }); - it('should pass session touch error', function (done) { - var cb = after(2, done) - var store = new session.MemoryStore() - var server = createServer({ store: store, resave: false }, function (req, res) { - req.session.hit = true - res.end('session saved') + describe('when true', function () { + it('should force save on unmodified session', function (done) { + var store = new session.MemoryStore() + var server = createServer({ store: store, resave: true }, function (req, res) { + req.session.user = 'bob' + res.end() + }) + + request(server) + .get('/') + .expect(shouldSetSessionInStore(store)) + .expect(200, function (err, res) { + if (err) return done(err) + request(server) + .get('/') + .set('Cookie', cookie(res)) + .expect(shouldSetSessionInStore(store)) + .expect(200, done) + }) }) + }) - store.touch = function touch(sid, sess, callback) { - callback(new Error('boom!')) - } + describe('when false', function () { + it('should prevent save on unmodified session', function (done) { + var store = new session.MemoryStore() + var server = createServer({ store: store, resave: false }, function (req, res) { + req.session.user = 'bob' + res.end() + }) - server.on('error', function onerror(err) { - assert.ok(err) - assert.equal(err.message, 'boom!') - cb() + request(server) + .get('/') + .expect(shouldSetSessionInStore(store)) + .expect(200, function (err, res) { + if (err) return done(err) + request(server) + .get('/') + .set('Cookie', cookie(res)) + .expect(shouldNotSetSessionInStore(store)) + .expect(200, done) + }) }) - request(server) - .get('/') - .expect(200, 'session saved', function (err, res) { - if (err) return cb(err) + it('should still save modified session', function (done) { + var store = new session.MemoryStore() + var server = createServer({ resave: false, store: store }, function (req, res) { + if (req.method === 'PUT') { + req.session.token = req.url.slice(1) + } + res.end('token=' + (req.session.token || '')) + }) + + request(server) + .put('/w6RHhwaA') + .expect(200) + .expect(shouldSetSessionInStore(store)) + .expect('token=w6RHhwaA') + .end(function (err, res) { + if (err) return done(err) + var sess = cookie(res) + request(server) + .get('/') + .set('Cookie', sess) + .expect(200) + .expect(shouldNotSetSessionInStore(store)) + .expect('token=w6RHhwaA') + .end(function (err) { + if (err) return done(err) + request(server) + .put('/zfQ3rzM3') + .set('Cookie', sess) + .expect(200) + .expect(shouldSetSessionInStore(store)) + .expect('token=zfQ3rzM3') + .end(done) + }) + }) + }) + + it('should detect a "cookie" property as modified', function (done) { + var store = new session.MemoryStore() + var server = createServer({ store: store, resave: false }, function (req, res) { + req.session.user = req.session.user || {} + req.session.user.name = 'bob' + req.session.user.cookie = req.session.user.cookie || 0 + req.session.user.cookie++ + res.end() + }) + + request(server) + .get('/') + .expect(shouldSetSessionInStore(store)) + .expect(200, function (err, res) { + if (err) return done(err) + request(server) + .get('/') + .set('Cookie', cookie(res)) + .expect(shouldSetSessionInStore(store)) + .expect(200, done) + }) + }) + + it('should pass session touch error', function (done) { + var cb = after(2, done) + var store = new session.MemoryStore() + var server = createServer({ store: store, resave: false }, function (req, res) { + req.session.hit = true + res.end('session saved') + }) + + store.touch = function touch (sid, sess, callback) { + callback(new Error('boom!')) + } + + server.on('error', function onerror (err) { + assert.ok(err) + assert.strictEqual(err.message, 'boom!') + cb() + }) + request(server) .get('/') - .set('Cookie', cookie(res)) - .end(cb) + .expect(200, 'session saved', function (err, res) { + if (err) return cb(err) + request(server) + .get('/') + .set('Cookie', cookie(res)) + .end(cb) + }) }) }) }); describe('saveUninitialized option', function(){ it('should default to true', function(done){ - var count = 0; - var app = express(); - app.use(session({ secret: 'keyboard cat', cookie: { maxAge: min }})); - app.use(function(req, res, next){ - var save = req.session.save; - res.setHeader('x-count', count); - req.session.save = function(fn){ - res.setHeader('x-count', ++count); - return save.call(this, fn); - }; - res.end(); - }); + var store = new session.MemoryStore() + var server = createServer({ store: store }) - request(app) + request(server) .get('/') - .expect('x-count', '1') + .expect(shouldSetSessionInStore(store)) .expect(shouldSetCookie('connect.sid')) .expect(200, done); }); it('should force save of uninitialized session', function(done){ - var count = 0; - var app = express(); - app.use(session({ saveUninitialized: true, secret: 'keyboard cat', cookie: { maxAge: min }})); - app.use(function(req, res, next){ - var save = req.session.save; - res.setHeader('x-count', count); - req.session.save = function(fn){ - res.setHeader('x-count', ++count); - return save.call(this, fn); - }; - res.end(); - }); + var store = new session.MemoryStore() + var server = createServer({ store: store, saveUninitialized: true }) - request(app) + request(server) .get('/') - .expect('x-count', '1') + .expect(shouldSetSessionInStore(store)) .expect(shouldSetCookie('connect.sid')) .expect(200, done); }); it('should prevent save of uninitialized session', function(done){ - var count = 0; - var app = express(); - app.use(session({ saveUninitialized: false, secret: 'keyboard cat', cookie: { maxAge: min }})); - app.use(function(req, res, next){ - var save = req.session.save; - res.setHeader('x-count', count); - req.session.save = function(fn){ - res.setHeader('x-count', ++count); - return save.call(this, fn); - }; - res.end(); - }); + var store = new session.MemoryStore() + var server = createServer({ store: store, saveUninitialized: false }) - request(app) + request(server) .get('/') - .expect('x-count', '0') + .expect(shouldNotSetSessionInStore(store)) .expect(shouldNotHaveHeader('Set-Cookie')) .expect(200, done) }); it('should still save modified session', function(done){ - var count = 0; - var app = express(); - app.use(session({ saveUninitialized: false, secret: 'keyboard cat', cookie: { maxAge: min }})); - app.use(function(req, res, next){ - var save = req.session.save; - res.setHeader('x-count', count); - req.session.count = count; - req.session.user = 'bob'; - req.session.save = function(fn){ - res.setHeader('x-count', ++count); - return save.call(this, fn); - }; - res.end(); - }); + var store = new session.MemoryStore() + var server = createServer({ store: store, saveUninitialized: false }, function (req, res) { + req.session.count = req.session.count || 0 + req.session.count++ + res.end() + }) - request(app) + request(server) .get('/') - .expect('x-count', '1') + .expect(shouldSetSessionInStore(store)) .expect(shouldSetCookie('connect.sid')) .expect(200, done); }); @@ -984,7 +1447,7 @@ describe('session()', function(){ server.on('error', function onerror(err) { assert.ok(err) - assert.equal(err.message, 'boom!') + assert.strictEqual(err.message, 'boom!') cb() }) @@ -1015,6 +1478,50 @@ describe('session()', function(){ assert.throws(createServer.bind(null, { secret: [] }), /secret option array/); }) + it('should sign and unsign with a string', function (done) { + var server = createServer({ secret: 'awesome cat' }, function (req, res) { + if (!req.session.user) { + req.session.user = 'bob' + res.end('set') + } else { + res.end('get:' + JSON.stringify(req.session.user)) + } + }) + + request(server) + .get('/') + .expect(shouldSetCookie('connect.sid')) + .expect(200, 'set', function (err, res) { + if (err) return done(err) + request(server) + .get('/') + .set('Cookie', cookie(res)) + .expect(200, 'get:"bob"', done) + }) + }) + + it('should sign and unsign with a Buffer', function (done) { + var server = createServer({ secret: crypto.randomBytes(32) }, function (req, res) { + if (!req.session.user) { + req.session.user = 'bob' + res.end('set') + } else { + res.end('get:' + JSON.stringify(req.session.user)) + } + }) + + request(server) + .get('/') + .expect(shouldSetCookie('connect.sid')) + .expect(200, 'set', function (err, res) { + if (err) return done(err) + request(server) + .get('/') + .set('Cookie', cookie(res)) + .expect(200, 'get:"bob"', done) + }) + }) + describe('when an array', function () { it('should sign cookies', function (done) { var server = createServer({ secret: ['keyboard cat', 'nyan cat'] }, function (req, res) { @@ -1085,30 +1592,28 @@ describe('session()', function(){ it('should default to keep', function(done){ var store = new session.MemoryStore(); - var app = express() - .use(session({ store: store, secret: 'keyboard cat' })) - .use(function(req, res, next){ - req.session.count = req.session.count || 0; - req.session.count++; - if (req.session.count === 2) req.session = null; - res.end(); - }); + var server = createServer({ store: store }, function (req, res) { + req.session.count = req.session.count || 0 + req.session.count++ + if (req.session.count === 2) req.session = null + res.end() + }) - request(app) + request(server) .get('/') .expect(200, function(err, res){ if (err) return done(err); store.length(function(err, len){ if (err) return done(err); - assert.equal(len, 1) - request(app) + assert.strictEqual(len, 1) + request(server) .get('/') .set('Cookie', cookie(res)) .expect(200, function(err, res){ if (err) return done(err); store.length(function(err, len){ if (err) return done(err); - assert.equal(len, 1) + assert.strictEqual(len, 1) done(); }); }); @@ -1118,30 +1623,28 @@ describe('session()', function(){ it('should allow destroy on req.session = null', function(done){ var store = new session.MemoryStore(); - var app = express() - .use(session({ store: store, unset: 'destroy', secret: 'keyboard cat' })) - .use(function(req, res, next){ - req.session.count = req.session.count || 0; - req.session.count++; - if (req.session.count === 2) req.session = null; - res.end(); - }); + var server = createServer({ store: store, unset: 'destroy' }, function (req, res) { + req.session.count = req.session.count || 0 + req.session.count++ + if (req.session.count === 2) req.session = null + res.end() + }) - request(app) + request(server) .get('/') .expect(200, function(err, res){ if (err) return done(err); store.length(function(err, len){ if (err) return done(err); - assert.equal(len, 1) - request(app) + assert.strictEqual(len, 1) + request(server) .get('/') .set('Cookie', cookie(res)) .expect(200, function(err, res){ if (err) return done(err); store.length(function(err, len){ if (err) return done(err); - assert.equal(len, 0) + assert.strictEqual(len, 0) done(); }); }); @@ -1151,21 +1654,19 @@ describe('session()', function(){ it('should not set cookie if initial session destroyed', function(done){ var store = new session.MemoryStore(); - var app = express() - .use(session({ store: store, unset: 'destroy', secret: 'keyboard cat' })) - .use(function(req, res, next){ - req.session = null; - res.end(); - }); + var server = createServer({ store: store, unset: 'destroy' }, function (req, res) { + req.session = null + res.end() + }) - request(app) + request(server) .get('/') .expect(shouldNotHaveHeader('Set-Cookie')) .expect(200, function(err, res){ if (err) return done(err); store.length(function(err, len){ if (err) return done(err); - assert.equal(len, 0) + assert.strictEqual(len, 0) done(); }); }); @@ -1185,7 +1686,7 @@ describe('session()', function(){ server.on('error', function onerror(err) { assert.ok(err) - assert.equal(err.message, 'boom!') + assert.strictEqual(err.message, 'boom!') cb() }) @@ -1197,36 +1698,69 @@ describe('session()', function(){ describe('res.end patch', function () { it('should correctly handle res.end/res.write patched prior', function (done) { - var app = express() + function setup (req, res) { + utils.writePatch(res) + } - app.use(writePatch()) - app.use(createSession()) - app.use(function (req, res) { + function respond (req, res) { req.session.hit = true res.write('hello, ') res.end('world') - }) + } - request(app) + request(createServer(setup, null, respond)) .get('/') .expect(200, 'hello, world', done) }) it('should correctly handle res.end/res.write patched after', function (done) { - var app = express() - - app.use(createSession()) - app.use(writePatch()) - app.use(function (req, res) { + function respond (req, res) { + utils.writePatch(res) req.session.hit = true res.write('hello, ') res.end('world') - }) + } - request(app) + request(createServer(null, respond)) .get('/') .expect(200, 'hello, world', done) }) + + it('should error when res.end is called twice', function (done) { + var error1 = null + var error2 = null + var server = http.createServer(function (req, res) { + res.end() + + try { + res.setHeader('Content-Length', '3') + res.end('foo') + } catch (e) { + error1 = e + } + }) + + function respond (req, res) { + res.end() + + try { + res.setHeader('Content-Length', '3') + res.end('foo') + } catch (e) { + error2 = e + } + } + + request(server) + .get('/') + .end(function (err, res) { + if (err) return done(err) + request(createServer(null, respond)) + .get('/') + .expect(function () { assert.strictEqual((error1 && error1.message), (error2 && error2.message)) }) + .expect(res.statusCode, res.text, done) + }) + }) }) describe('req.session', function(){ @@ -1255,22 +1789,19 @@ describe('session()', function(){ it('should only set-cookie when modified', function(done){ var modify = true; + var server = createServer(null, function (req, res) { + if (modify) { + req.session.count = req.session.count || 0 + req.session.count++ + } + res.end(req.session.count.toString()) + }) - var app = express() - .use(session({ secret: 'keyboard cat', cookie: { maxAge: min }})) - .use(function(req, res, next){ - if (modify) { - req.session.count = req.session.count || 0; - req.session.count++; - } - res.end(req.session.count.toString()); - }); - - request(app) + request(server) .get('/') .expect(200, '1', function (err, res) { if (err) return done(err) - request(app) + request(server) .get('/') .set('Cookie', cookie(res)) .expect(200, '2', function (err, res) { @@ -1278,7 +1809,7 @@ describe('session()', function(){ var val = cookie(res); modify = false; - request(app) + request(server) .get('/') .set('Cookie', val) .expect(shouldNotHaveHeader('Set-Cookie')) @@ -1286,7 +1817,7 @@ describe('session()', function(){ if (err) return done(err) modify = true; - request(app) + request(server) .get('/') .set('Cookie', val) .expect(shouldSetCookie('connect.sid')) @@ -1296,53 +1827,95 @@ describe('session()', function(){ }); }) + it('should not have enumerable methods', function (done) { + var server = createServer(null, function (req, res) { + req.session.foo = 'foo' + req.session.bar = 'bar' + var keys = [] + for (var key in req.session) { + keys.push(key) + } + res.end(keys.sort().join(',')) + }) + + request(server) + .get('/') + .expect(200, 'bar,cookie,foo', done); + }); + + it('should not be set if store is disconnected', function (done) { + var store = new session.MemoryStore() + var server = createServer({ store: store }, function (req, res) { + res.end(typeof req.session) + }) + + store.emit('disconnect') + + request(server) + .get('/') + .expect(shouldNotHaveHeader('Set-Cookie')) + .expect(200, 'undefined', done) + }) + + it('should be set when store reconnects', function (done) { + var store = new session.MemoryStore() + var server = createServer({ store: store }, function (req, res) { + res.end(typeof req.session) + }) + + store.emit('disconnect') + + request(server) + .get('/') + .expect(shouldNotHaveHeader('Set-Cookie')) + .expect(200, 'undefined', function (err) { + if (err) return done(err) + + store.emit('connect') + + request(server) + .get('/') + .expect(200, 'object', done) + }) + }) + describe('.destroy()', function(){ it('should destroy the previous session', function(done){ - var app = express() - .use(session({ secret: 'keyboard cat' })) - .use(function(req, res, next){ - req.session.destroy(function(err){ - if (err) throw err; - assert(!req.session, 'req.session after destroy'); - res.end(); - }); - }); + var server = createServer(null, function (req, res) { + req.session.destroy(function (err) { + if (err) res.statusCode = 500 + res.end(String(req.session)) + }) + }) - request(app) + request(server) .get('/') .expect(shouldNotHaveHeader('Set-Cookie')) - .expect(200, done) + .expect(200, 'undefined', done) }) }) describe('.regenerate()', function(){ it('should destroy/replace the previous session', function(done){ - var app = express() - .use(session({ secret: 'keyboard cat', cookie: { maxAge: min }})) - .use(function(req, res, next){ - var id = req.session.id; - req.session.regenerate(function(err){ - if (err) throw err; - assert.notEqual(id, req.session.id) - res.end(); - }); - }); + var server = createServer(null, function (req, res) { + var id = req.session.id + req.session.regenerate(function (err) { + if (err) res.statusCode = 500 + res.end(String(req.session.id === id)) + }) + }) - request(app) + request(server) .get('/') .expect(shouldSetCookie('connect.sid')) .expect(200, function (err, res) { if (err) return done(err) - var id = sid(res) - request(app) + request(server) .get('/') .set('Cookie', cookie(res)) .expect(shouldSetCookie('connect.sid')) - .expect(200, function (err, res) { - if (err) return done(err) - assert.notEqual(sid(res), id) - done(); - }); + .expect(shouldSetCookieToDifferentSessionId(sid(res))) + .expect(200, 'false', done) }); }) }) @@ -1416,6 +1989,49 @@ describe('session()', function(){ .expect(500, 'failed to load session', done) }) }) + + it('should not override an overridden `reload` in case of errors', function (done) { + var store = new session.MemoryStore() + var server = createServer({ store: store, resave: false }, function (req, res) { + if (req.url === '/') { + req.session.active = true + res.end('session created') + return + } + + store.clear(function (err) { + if (err) return done(err) + + // reload way too many times on top of each other, + // attempting to overflow the call stack + var iters = 20 + reload() + function reload () { + if (!--iters) { + res.end('ok') + return + } + + try { + req.session.reload(reload) + } catch (e) { + res.statusCode = 500 + res.end(e.message) + } + } + }) + }) + + request(server) + .get('/') + .expect(200, 'session created', function (err, res) { + if (err) return done(err) + request(server) + .get('/foo') + .set('Cookie', cookie(res)) + .expect(200, 'ok', done) + }) + }) }) describe('.save()', function () { @@ -1438,7 +2054,6 @@ describe('session()', function(){ }) it('should prevent end-of-request save', function (done) { - var count = 0 var store = new session.MemoryStore() var server = createServer({ store: store }, function (req, res) { req.session.hit = true @@ -1448,26 +2063,66 @@ describe('session()', function(){ }) }) - var _set = store.set - store.set = function set(sid, sess, callback) { - count++ - _set.call(store, sid, sess, callback) - } + request(server) + .get('/') + .expect(shouldSetSessionInStore(store)) + .expect(200, 'saved', function (err, res) { + if (err) return done(err) + request(server) + .get('/') + .set('Cookie', cookie(res)) + .expect(shouldSetSessionInStore(store)) + .expect(200, 'saved', done) + }) + }) + + it('should prevent end-of-request save on reloaded session', function (done) { + var store = new session.MemoryStore() + var server = createServer({ store: store }, function (req, res) { + req.session.hit = true + req.session.reload(function () { + req.session.save(function (err) { + if (err) return res.end(err.message) + res.end('saved') + }) + }) + }) request(server) .get('/') + .expect(shouldSetSessionInStore(store)) .expect(200, 'saved', function (err, res) { if (err) return done(err) - assert.equal(count, 1) - count = 0 request(server) .get('/') .set('Cookie', cookie(res)) - .expect(200, 'saved', function (err) { - if (err) return done(err) - assert.equal(count, 1) - done() + .expect(shouldSetSessionInStore(store)) + .expect(200, 'saved', done) + }) + }) + + describe('when saveUninitialized is false', function () { + it('should prevent end-of-request save', function (done) { + var store = new session.MemoryStore() + var server = createServer({ saveUninitialized: false, store: store }, function (req, res) { + req.session.hit = true + req.session.save(function (err) { + if (err) return res.end(err.message) + res.end('saved') + }) }) + + request(server) + .get('/') + .expect(shouldSetSessionInStore(store)) + .expect(200, 'saved', function (err, res) { + if (err) return done(err) + request(server) + .get('/') + .set('Cookie', cookie(res)) + .expect(shouldSetSessionInStore(store)) + .expect(200, 'saved', done) + }) }) }) }) @@ -1497,7 +2152,7 @@ describe('session()', function(){ if (err) return done(err); store.get(id, function (err, sess) { if (err) return done(err) - assert.notEqual(new Date(sess.cookie.expires).getTime(), exp.getTime()) + assert.notStrictEqual(new Date(sess.cookie.expires).getTime(), exp.getTime()) done() }) }) @@ -1510,56 +2165,36 @@ describe('session()', function(){ describe('.cookie', function(){ describe('.*', function(){ it('should serialize as parameters', function(done){ - var app = express() - .use(session({ secret: 'keyboard cat', proxy: true, cookie: { maxAge: min }})) - .use(function(req, res, next){ - req.session.cookie.httpOnly = false; - req.session.cookie.secure = true; - res.end(); - }); + var server = createServer({ proxy: true }, function (req, res) { + req.session.cookie.httpOnly = false + req.session.cookie.secure = true + res.end() + }) - request(app) + request(server) .get('/') .set('X-Forwarded-Proto', 'https') - .expect(200, function(err, res){ - if (err) return done(err); - var val = cookie(res); - assert.equal(val.indexOf('HttpOnly'), -1, 'should not be HttpOnly cookie') - assert.notEqual(val.indexOf('Secure'), -1, 'should be Secure cookie') - done(); - }); + .expect(shouldSetCookieWithoutAttribute('connect.sid', 'HttpOnly')) + .expect(shouldSetCookieWithAttribute('connect.sid', 'Secure')) + .expect(200, done) }) it('should default to a browser-session length cookie', function(done){ - var app = express() - .use(session({ secret: 'keyboard cat', cookie: { path: '/admin' }})) - .use(function(req, res, next){ - res.end(); - }); - - request(app) + request(createServer({ cookie: { path: '/admin' } })) .get('/admin') - .expect(200, function(err, res){ - if (err) return done(err); - var val = cookie(res); - assert.equal(val.indexOf('Expires'), -1, 'should be not have cookie Expires') - done(); - }); + .expect(shouldSetCookieWithoutAttribute('connect.sid', 'Expires')) + .expect(200, done) }) it('should Set-Cookie only once for browser-session cookies', function(done){ - var app = express() - .use(session({ secret: 'keyboard cat', cookie: { path: '/admin' }})) - .use(function(req, res, next){ - res.end(); - }); + var server = createServer({ cookie: { path: '/admin' } }) - request(app) + request(server) .get('/admin/foo') .expect(shouldSetCookie('connect.sid')) .expect(200, function (err, res) { if (err) return done(err) - request(app) + request(server) .get('/admin') .set('Cookie', cookie(res)) .expect(shouldNotHaveHeader('Set-Cookie')) @@ -1568,40 +2203,151 @@ describe('session()', function(){ }) it('should override defaults', function(done){ - var app = express() - .use(session({ secret: 'keyboard cat', cookie: { path: '/admin', httpOnly: false, secure: true, maxAge: 5000 }})) - .use(function(req, res, next){ - req.session.cookie.secure = false; - res.end(); - }); + var opts = { + httpOnly: false, + maxAge: 5000, + path: '/admin', + priority: 'high', + secure: true + } + var server = createServer({ cookie: opts }, function (req, res) { + req.session.cookie.secure = false + res.end() + }) - request(app) - .get('/admin') - .expect(200, function(err, res){ - if (err) return done(err); - var val = cookie(res); - assert.equal(val.indexOf('HttpOnly'), -1, 'should not be HttpOnly cookie') - assert.equal(val.indexOf('Secure'), -1, 'should not be Secure cookie') - assert.notEqual(val.indexOf('Path=/admin'), -1, 'should have cookie path /admin') - assert.notEqual(val.indexOf('Expires'), -1, 'should have cookie Expires') - done(); - }); + request(server) + .get('/admin') + .expect(shouldSetCookieWithAttribute('connect.sid', 'Expires')) + .expect(shouldSetCookieWithoutAttribute('connect.sid', 'HttpOnly')) + .expect(shouldSetCookieWithAttributeAndValue('connect.sid', 'Path', '/admin')) + .expect(shouldSetCookieWithoutAttribute('connect.sid', 'Secure')) + .expect(shouldSetCookieWithAttributeAndValue('connect.sid', 'Priority', 'High')) + .expect(200, done) + }) + + it('should forward errors setting cookie', function (done) { + var cb = after(2, done) + var server = createServer({ cookie: { expires: new Date(NaN) } }, function (req, res) { + res.end() + }) + + server.on('error', function onerror (err) { + assert.ok(err) + assert.strictEqual(err.message, 'option expires is invalid') + cb() + }) + + request(server) + .get('/admin') + .expect(200, cb) }) it('should preserve cookies set before writeHead is called', function(done){ - var app = express() - .use(session({ secret: 'keyboard cat' })) - .use(function(req, res, next){ - var cookie = new Cookie(); - res.setHeader('Set-Cookie', cookie.serialize('previous', 'cookieValue')); - res.end(); - }); + var server = createServer(null, function (req, res) { + var cookie = new Cookie() + res.setHeader('Set-Cookie', cookie.serialize('previous', 'cookieValue')) + res.end() + }) - request(app) + request(server) .get('/') .expect(shouldSetCookieToValue('previous', 'cookieValue')) .expect(200, done) }) + + it('should preserve cookies set in writeHead', function (done) { + var server = createServer(null, function (req, res) { + var cookie = new Cookie() + res.writeHead(200, { + 'Set-Cookie': cookie.serialize('previous', 'cookieValue') + }) + res.end() + }) + + request(server) + .get('/') + .expect(shouldSetCookieToValue('previous', 'cookieValue')) + .expect(200, done) + }) + }) + + describe('.originalMaxAge', function () { + it('should equal original maxAge', function (done) { + var server = createServer({ cookie: { maxAge: 2000 } }, function (req, res) { + res.end(JSON.stringify(req.session.cookie.originalMaxAge)) + }) + + request(server) + .get('/') + .expect(200) + .expect(function (res) { + // account for 1ms latency + assert.ok(res.text === '2000' || res.text === '1999', + 'expected 2000, got ' + res.text) + }) + .end(done) + }) + + it('should equal original maxAge for all requests', function (done) { + var server = createServer({ cookie: { maxAge: 2000 } }, function (req, res) { + res.end(JSON.stringify(req.session.cookie.originalMaxAge)) + }) + + request(server) + .get('/') + .expect(200) + .expect(function (res) { + // account for 1ms latency + assert.ok(res.text === '2000' || res.text === '1999', + 'expected 2000, got ' + res.text) + }) + .end(function (err, res) { + if (err) return done(err) + setTimeout(function () { + request(server) + .get('/') + .set('Cookie', cookie(res)) + .expect(200) + .expect(function (res) { + // account for 1ms latency + assert.ok(res.text === '2000' || res.text === '1999', + 'expected 2000, got ' + res.text) + }) + .end(done) + }, 100) + }) + }) + + it('should equal original maxAge for all requests', function (done) { + var store = new SmartStore() + var server = createServer({ cookie: { maxAge: 2000 }, store: store }, function (req, res) { + res.end(JSON.stringify(req.session.cookie.originalMaxAge)) + }) + + request(server) + .get('/') + .expect(200) + .expect(function (res) { + // account for 1ms latency + assert.ok(res.text === '2000' || res.text === '1999', + 'expected 2000, got ' + res.text) + }) + .end(function (err, res) { + if (err) return done(err) + setTimeout(function () { + request(server) + .get('/') + .set('Cookie', cookie(res)) + .expect(200) + .expect(function (res) { + // account for 1ms latency + assert.ok(res.text === '2000' || res.text === '1999', + 'expected 2000, got ' + res.text) + }) + .end(done) + }, 100) + }) + }) }) describe('.secure', function(){ @@ -1644,172 +2390,161 @@ describe('session()', function(){ }) }) - describe('when the pathname does not match cookie.path', function(){ - it('should not set-cookie', function(done){ - var app = express() - .use(session({ secret: 'keyboard cat', cookie: { path: '/foo/bar' }})) - .use(function(req, res, next){ - if (!req.session) { - return res.end(); - } - req.session.foo = Math.random(); - res.end(); - }); + describe('.maxAge', function () { + before(function (done) { + var ctx = this + + ctx.cookie = '' + ctx.server = createServer({ cookie: { maxAge: 2000 } }, function (req, res) { + switch (++req.session.count) { + case 1: + break + case 2: + req.session.cookie.maxAge = 5000 + break + case 3: + req.session.cookie.maxAge = 3000000000 + break + default: + req.session.count = 0 + break + } + res.end(req.session.count.toString()) + }) - request(app) + request(ctx.server) .get('/') - .expect(shouldNotHaveHeader('Set-Cookie')) - .expect(200, done) + .end(function (err, res) { + ctx.cookie = res && cookie(res) + done(err) + }) }) - it('should not set-cookie even for FQDN', function(done){ - var app = express() - .use(session({ secret: 'keyboard cat', cookie: { path: '/foo/bar' }})) - .use(function(req, res, next){ - if (!req.session) { - return res.end(); - } + it('should set cookie expires relative to maxAge', function (done) { + request(this.server) + .get('/') + .set('Cookie', this.cookie) + .expect(shouldSetCookieToExpireIn('connect.sid', 2000)) + .expect(200, '1', done) + }) - req.session.foo = Math.random(); - res.end(); - }); + it('should modify cookie expires when changed', function (done) { + request(this.server) + .get('/') + .set('Cookie', this.cookie) + .expect(shouldSetCookieToExpireIn('connect.sid', 5000)) + .expect(200, '2', done) + }) - request(app) + it('should modify cookie expires when changed to large value', function (done) { + request(this.server) .get('/') - .set('host', 'http://foo/bar') - .expect(shouldNotHaveHeader('Set-Cookie')) - .expect(200, done) + .set('Cookie', this.cookie) + .expect(shouldSetCookieToExpireIn('connect.sid', 3000000000)) + .expect(200, '3', done) }) }) - describe('when the pathname does match cookie.path', function(){ - it('should set-cookie', function(done){ - var app = express() - .use(session({ secret: 'keyboard cat', cookie: { path: '/foo/bar' }})) - .use(function(req, res, next){ - req.session.foo = Math.random(); + describe('.expires', function(){ + describe('when given a Date', function(){ + it('should set absolute', function(done){ + var server = createServer(null, function (req, res) { + req.session.cookie.expires = new Date(0) + res.end() + }) + + request(server) + .get('/') + .expect(shouldSetCookieWithAttributeAndValue('connect.sid', 'Expires', 'Thu, 01 Jan 1970 00:00:00 GMT')) + .expect(200, done) + }) + }) + + describe('when null', function(){ + it('should be a browser-session cookie', function(done){ + var server = createServer(null, function (req, res) { + req.session.cookie.expires = null + res.end() + }) + + request(server) + .get('/') + .expect(shouldSetCookieWithoutAttribute('connect.sid', 'Expires')) + .expect(200, done) + }) + + it('should not reset cookie', function (done) { + var server = createServer(null, function (req, res) { + req.session.cookie.expires = null; res.end(); }); - request(app) - .get('/foo/bar/baz') - .expect(shouldSetCookie('connect.sid')) - .expect(200, done) - }) + request(server) + .get('/') + .expect(shouldSetCookieWithoutAttribute('connect.sid', 'Expires')) + .expect(200, function (err, res) { + if (err) return done(err); + request(server) + .get('/') + .set('Cookie', cookie(res)) + .expect(shouldNotHaveHeader('Set-Cookie')) + .expect(200, done) + }); + }) - it('should set-cookie even for FQDN', function(done){ - var app = express() - .use(session({ secret: 'keyboard cat', cookie: { path: '/foo/bar' }})) - .use(function(req, res, next){ - req.session.foo = Math.random(); + it('should not reset cookie when modified', function (done) { + var server = createServer(null, function (req, res) { + req.session.cookie.expires = null; + req.session.hit = (req.session.hit || 0) + 1; res.end(); }); - request(app) - .get('/foo/bar/baz') - .set('host', 'http://example.com') - .expect(shouldSetCookie('connect.sid')) - .expect(200, done) + request(server) + .get('/') + .expect(shouldSetCookieWithoutAttribute('connect.sid', 'Expires')) + .expect(200, function (err, res) { + if (err) return done(err); + request(server) + .get('/') + .set('Cookie', cookie(res)) + .expect(shouldNotHaveHeader('Set-Cookie')) + .expect(200, done) + }); + }) }) }) - describe('.maxAge', function(){ - var val; - var app = express() - .use(session({ secret: 'keyboard cat', cookie: { maxAge: 2000 }})) - .use(function(req, res, next){ - req.session.count = req.session.count || 0; - req.session.count++; - if (req.session.count == 2) req.session.cookie.maxAge = 5000; - if (req.session.count == 3) req.session.cookie.maxAge = 3000000000; - res.end(req.session.count.toString()); - }); - - it('should set relative in milliseconds', function(done){ - request(app) - .get('/') - .expect(200, '1', function (err, res) { - var a = new Date(expires(res)) - var b = new Date - var delta = a.valueOf() - b.valueOf() - - val = cookie(res); - - assert.ok(delta > 1000 && delta <= 2000) - done(); - }); - }); - - it('should modify cookie when changed', function(done){ - request(app) - .get('/') - .set('Cookie', val) - .expect(200, '2', function (err, res) { - var a = new Date(expires(res)) - var b = new Date - var delta = a.valueOf() - b.valueOf() - - val = cookie(res); - - assert.ok(delta > 4000 && delta <= 5000) - done(); - }); - }); - - it('should modify cookie when changed to large value', function(done){ - request(app) - .get('/') - .set('Cookie', val) - .expect(200, '3', function (err, res) { - var a = new Date(expires(res)) - var b = new Date - var delta = a.valueOf() - b.valueOf() + describe('.partitioned', function () { + describe('by default', function () { + it('should not set partitioned attribute', function (done) { + var server = createServer() - val = cookie(res); + request(server) + .get('/') + .expect(shouldSetCookieWithoutAttribute('connect.sid', 'Partitioned')) + .expect(200, done) + }) + }) - assert.ok(delta > 2999999000 && delta <= 3000000000) - done(); - }); - }); - }) + describe('when "false"', function () { + it('should not set partitioned attribute', function (done) { + var server = createServer({ cookie: { partitioned: false } }) - describe('.expires', function(){ - describe('when given a Date', function(){ - it('should set absolute', function(done){ - var app = express() - .use(session({ secret: 'keyboard cat' })) - .use(function(req, res, next){ - req.session.cookie.expires = new Date(0); - res.end(); - }); - - request(app) - .get('/') - .end(function(err, res){ - if (err) return done(err) - assert.equal(expires(res), 'Thu, 01 Jan 1970 00:00:00 GMT') - done(); - }); + request(server) + .get('/') + .expect(shouldSetCookieWithoutAttribute('connect.sid', 'Partitioned')) + .expect(200, done) }) }) - describe('when null', function(){ - it('should be a browser-session cookie', function(done){ - var app = express() - .use(session({ secret: 'keyboard cat' })) - .use(function(req, res, next){ - req.session.cookie.expires = null; - res.end(); - }); - - request(app) - .get('/') - .expect(200, function(err, res){ - if (err) return done(err); - var val = cookie(res); - assert.equal(val.indexOf('Expires'), -1, 'should be not have cookie Expires') - done(); - }); + describe('when "true"', function () { + it('should set partitioned attribute', function (done) { + var server = createServer({ cookie: { partitioned: true } }) + + request(server) + .get('/') + .expect(shouldSetCookieWithAttribute('connect.sid', 'Partitioned')) + .expect(200, done) }) }) }) @@ -1859,7 +2594,7 @@ describe('session()', function(){ var app = express() .use(cookieParser()) .use(function(req, res, next){ req.headers.cookie = 'foo=bar'; next() }) - .use(session({ secret: 'keyboard cat' })) + .use(createSession()) .use(function(req, res, next){ req.session.count = req.session.count || 0 req.session.count++ @@ -1881,7 +2616,7 @@ describe('session()', function(){ var app = express() .use(cookieParser()) .use(function(req, res, next){ req.headers.cookie = 'foo=bar'; next() }) - .use(session({ secret: 'keyboard cat', key: 'sessid' })) + .use(createSession({ key: 'sessid' })) .use(function(req, res, next){ req.session.count = req.session.count || 0 req.session.count++ @@ -1903,7 +2638,7 @@ describe('session()', function(){ var app = express() .use(cookieParser()) .use(function(req, res, next){ req.headers.cookie = 'foo=bar'; next() }) - .use(session({ secret: 'keyboard cat', key: 'sessid' })) + .use(createSession({ key: 'sessid' })) .use(function(req, res, next){ req.session.count = req.session.count || 0 req.session.count++ @@ -1926,7 +2661,7 @@ describe('session()', function(){ var app = express() .use(cookieParser('keyboard cat')) .use(function(req, res, next){ delete req.headers.cookie; next() }) - .use(session()) + .use(createSession()) .use(function(req, res, next){ req.session.count = req.session.count || 0 req.session.count++ @@ -1946,13 +2681,134 @@ describe('session()', function(){ }) }) +describe('path matching (RFC 6265)', function () { + describe('when "path" is "/" (root path)', function () { + before(function () { + this.server = createServer({ cookie: { path: '/' } }) + }) + + it('should set cookie when request-path is "/" (root path)', function (done) { + // RFC 6265 5.1.4: "The cookie-path and the request-path are identical." + request(this.server) + .get('/') + .expect(shouldSetCookie('connect.sid')) + .expect(200, done) + }) + + it('should set cookie when request-path is any path ("/foo")', function (done) { + // RFC 6265 5.1.4: "The cookie-path is a prefix of the request-path, and the last + // character of the cookie-path is %x2F ("/")." + request(this.server) + .get('/foo') + .expect(shouldSetCookie('connect.sid')) + .expect(200, done) + }) + + it('should set cookie when request-path has multiple segments ("/foo/bar/baz")', function (done) { + // RFC 6265 5.1.4: "The cookie-path is a prefix of the request-path, and the last + // character of the cookie-path is %x2F ("/")." + request(this.server) + .get('/foo/bar/baz') + .expect(shouldSetCookie('connect.sid')) + .expect(200, done) + }) + }) + + describe('when "path" is "/admin"', function () { + before(function () { + this.server = createServer({ cookie: { path: '/admin' } }) + }) + + it('should set cookie when request-path and cookie-path are identical ("/admin")', function (done) { + // RFC 6265 5.1.4: "The cookie-path and the request-path are identical." + request(this.server) + .get('/admin') + .expect(shouldSetCookie('connect.sid')) + .expect(200, done) + }) + + it('should set cookie when cookie-path is prefix and last char is "/" ("/admin/")', function (done) { + // RFC 6265 5.1.4: "The cookie-path is a prefix of the request-path, and the last + // character of the cookie-path is %x2F ("/")." + request(this.server) + .get('/admin/') + .expect(shouldSetCookie('connect.sid')) + .expect(200, done) + }) + + it('should set cookie when cookie-path is prefix and next char is "/" ("/admin/users")', function (done) { + // RFC 6265 5.1.4: "The cookie-path is a prefix of the request-path, and the first + // character of the request-path that is not included in the cookie-path is a %x2F ("/") character." + request(this.server) + .get('/admin/users') + .expect(shouldSetCookie('connect.sid')) + .expect(200, done) + }) + + it('should NOT set cookie when cookie-path is not a prefix ("/administrator")', function (done) { + // RFC 6265 5.1.4: None of the path-match conditions are met + request(this.server) + .get('/administrator') + .expect(shouldNotHaveHeader('Set-Cookie')) + .expect(200, done) + }) + }) + + describe('when "path" is "/admin/" (trailing slash)', function () { + before(function () { + this.server = createServer({ cookie: { path: '/admin/' } }) + }) + + it('should set cookie when cookie-path is prefix and last char is "/" ("/admin/x")', function (done) { + // RFC 6265 5.1.4: "The cookie-path is a prefix of the request-path, and the last + // character of the cookie-path is %x2F ("/")." + request(this.server) + .get('/admin/x') + .expect(shouldSetCookie('connect.sid')) + .expect(200, done) + }) + + it('should NOT set cookie when request-path is not prefixed by cookie-path ("/admin")', function (done) { + // RFC 6265 5.1.4: cookie-path "/admin/" is not a prefix of request-path "/admin" + request(this.server) + .get('/admin') + .expect(shouldNotHaveHeader('Set-Cookie')) + .expect(200, done) + }) + + it('should NOT set cookie when cookie-path is not a prefix ("/administrator")', function (done) { + // RFC 6265 5.1.4: None of the path-match conditions are met: + // 1. The paths are not identical + // 2. "/admin/" is not a prefix of "/administrator" + // 3. The prefix condition with next character "/" is not applicable + request(this.server) + .get('/administrator') + .expect(shouldNotHaveHeader('Set-Cookie')) + .expect(200, done) + }) + }) +}) + + function cookie(res) { var setCookie = res.headers['set-cookie']; return (setCookie && setCookie[0]) || undefined; } -function createServer(opts, fn) { - return http.createServer(createRequestListener(opts, fn)) +function createServer (options, respond) { + var fn = respond + var opts = options + var server = http.createServer() + + // setup, options, respond + if (typeof arguments[0] === 'function') { + opts = arguments[1] + fn = arguments[2] + + server.on('request', arguments[0]) + } + + return server.on('request', createRequestListener(opts, fn)) } function createRequestListener(opts, fn) { @@ -1997,9 +2853,18 @@ function end(req, res) { res.end() } -function expires(res) { - var match = /Expires=([^;]+)/.exec(cookie(res)); - return match ? match[1] : undefined; +function expires (res) { + var header = cookie(res) + return header && utils.parseSetCookie(header).expires +} + +function mountAt (path) { + return function (req, res) { + if (req.url.indexOf(path) === 0) { + req.originalUrl = req.url + req.url = req.url.slice(path.length) + } + } } function shouldNotHaveHeader(header) { @@ -2008,68 +2873,118 @@ function shouldNotHaveHeader(header) { } } -function shouldSetCookie(name) { +function shouldNotSetSessionInStore(store) { + var _set = store.set + var count = 0 + + store.set = function set () { + count++ + return _set.apply(this, arguments) + } + + return function () { + assert.ok(count === 0, 'should not set session in store') + } +} + +function shouldSetCookie (name) { + return function (res) { + var header = cookie(res) + var data = header && utils.parseSetCookie(header) + assert.ok(header, 'should have a cookie header') + assert.strictEqual(data.name, name, 'should set cookie ' + name) + } +} + +function shouldSetCookieToDifferentSessionId (id) { + return function (res) { + assert.notStrictEqual(sid(res), id) + } +} + +function shouldSetCookieToExpireIn (name, delta) { + return function (res) { + var header = cookie(res) + var data = header && utils.parseSetCookie(header) + assert.ok(header, 'should have a cookie header') + assert.strictEqual(data.name, name, 'should set cookie ' + name) + assert.ok(('expires' in data), 'should set cookie with attribute Expires') + assert.ok(('date' in res.headers), 'should have a date header') + assert.strictEqual((Date.parse(data.expires) - Date.parse(res.headers.date)), delta, 'should set cookie ' + name + ' to expire in ' + delta + ' ms') + } +} + +function shouldSetCookieToValue (name, val) { + return function (res) { + var header = cookie(res) + var data = header && utils.parseSetCookie(header) + assert.ok(header, 'should have a cookie header') + assert.strictEqual(data.name, name, 'should set cookie ' + name) + assert.strictEqual(data.value, val, 'should set cookie ' + name + ' to ' + val) + } +} + +function shouldSetCookieWithAttribute (name, attrib) { return function (res) { var header = cookie(res) + var data = header && utils.parseSetCookie(header) assert.ok(header, 'should have a cookie header') - assert.equal(header.split('=')[0], name, 'should set cookie ' + name) + assert.strictEqual(data.name, name, 'should set cookie ' + name) + assert.ok((attrib.toLowerCase() in data), 'should set cookie with attribute ' + attrib) } } -function shouldSetCookieToValue(name, val) { +function shouldSetCookieWithAttributeAndValue (name, attrib, value) { return function (res) { - var header = cookie(res); + var header = cookie(res) + var data = header && utils.parseSetCookie(header) assert.ok(header, 'should have a cookie header') - assert.equal(header.split('=')[0], name, 'should set cookie ' + name) - assert.equal(header.split('=')[1].split(';')[0], val, 'should set cookie ' + name + ' to ' + val) + assert.strictEqual(data.name, name, 'should set cookie ' + name) + assert.ok((attrib.toLowerCase() in data), 'should set cookie with attribute ' + attrib) + assert.strictEqual(data[attrib.toLowerCase()], value, 'should set cookie with attribute ' + attrib + ' set to ' + value) } } -function sid(res) { - var match = /^[^=]+=s%3A([^;\.]+)[\.;]/.exec(cookie(res)) - var val = match ? match[1] : undefined - return val +function shouldSetCookieWithoutAttribute (name, attrib) { + return function (res) { + var header = cookie(res) + var data = header && utils.parseSetCookie(header) + assert.ok(header, 'should have a cookie header') + assert.strictEqual(data.name, name, 'should set cookie ' + name) + assert.ok(!(attrib.toLowerCase() in data), 'should set cookie without attribute ' + attrib) + } } -function writePatch() { - var ended = false - return function addWritePatch(req, res, next) { - var _end = res.end - var _write = res.write +function shouldSetSessionInStore (store, delay) { + var _set = store.set + var count = 0 - res.end = function end() { - ended = true - return _end.apply(this, arguments) + store.set = function set () { + count++ + + if (!delay) { + return _set.apply(this, arguments) } - res.write = function write() { - if (ended) { - throw new Error('write after end') - } + var args = new Array(arguments.length + 1) - return _write.apply(this, arguments) + args[0] = this + for (var i = 1; i < args.length; i++) { + args[i] = arguments[i - 1] } - next() + setTimeout(_set.bind.apply(_set, args), delay) } -} -function SyncStore() { - this.sessions = Object.create(null); + return function () { + assert.ok(count === 1, 'should set session in store') + } } -SyncStore.prototype.__proto__ = session.Store.prototype; - -SyncStore.prototype.destroy = function destroy(sid, callback) { - delete this.sessions[sid]; - callback(); -}; - -SyncStore.prototype.get = function get(sid, callback) { - callback(null, JSON.parse(this.sessions[sid])); -}; - -SyncStore.prototype.set = function set(sid, sess, callback) { - this.sessions[sid] = JSON.stringify(sess); - callback(); -}; +function sid (res) { + var header = cookie(res) + var data = header && utils.parseSetCookie(header) + var value = data && unescape(data.value) + var sid = value && value.substring(2, value.indexOf('.')) + return sid || undefined +} diff --git a/test/support/env.js b/test/support/env.js new file mode 100644 index 00000000..fba1bd89 --- /dev/null +++ b/test/support/env.js @@ -0,0 +1,2 @@ + +process.env.NO_DEPRECATION = 'express-session'; diff --git a/test/support/gencert.sh b/test/support/gencert.sh new file mode 100755 index 00000000..cfdda070 --- /dev/null +++ b/test/support/gencert.sh @@ -0,0 +1,5 @@ +#! /bin/sh +set -ex + +openssl req -x509 -nodes -newkey rsa:2048 -keyout ./test/fixtures/server.key -out ./test/fixtures/server.crt -days 3650 \ +-subj "/C=US/ST=Illinois/L=Chicago/O=node-express-session/CN=express-session.local" diff --git a/test/support/smart-store.js b/test/support/smart-store.js new file mode 100644 index 00000000..8b224fdd --- /dev/null +++ b/test/support/smart-store.js @@ -0,0 +1,54 @@ +'use strict' + +var session = require('../../') +var util = require('util') + +/* istanbul ignore next */ +var defer = typeof setImmediate === 'function' + ? setImmediate + : function(fn){ process.nextTick(fn.bind.apply(fn, arguments)) } + +module.exports = SmartStore + +function SmartStore () { + session.Store.call(this) + this.sessions = Object.create(null) +} + +util.inherits(SmartStore, session.Store) + +SmartStore.prototype.destroy = function destroy (sid, callback) { + delete this.sessions[sid] + defer(callback, null) +} + +SmartStore.prototype.get = function get (sid, callback) { + var sess = this.sessions[sid] + + if (!sess) { + return + } + + // parse + sess = JSON.parse(sess) + + if (sess.cookie) { + // expand expires into Date object + sess.cookie.expires = typeof sess.cookie.expires === 'string' + ? new Date(sess.cookie.expires) + : sess.cookie.expires + + // destroy expired session + if (sess.cookie.expires && sess.cookie.expires <= Date.now()) { + delete this.sessions[sid] + sess = null + } + } + + defer(callback, null, sess) +} + +SmartStore.prototype.set = function set (sid, sess, callback) { + this.sessions[sid] = JSON.stringify(sess) + defer(callback, null) +} diff --git a/test/support/sync-store.js b/test/support/sync-store.js new file mode 100644 index 00000000..b45dc9a8 --- /dev/null +++ b/test/support/sync-store.js @@ -0,0 +1,27 @@ +'use strict' + +var session = require('../../') +var util = require('util') + +module.exports = SyncStore + +function SyncStore () { + session.Store.call(this) + this.sessions = Object.create(null) +} + +util.inherits(SyncStore, session.Store) + +SyncStore.prototype.destroy = function destroy (sid, callback) { + delete this.sessions[sid] + callback() +} + +SyncStore.prototype.get = function get (sid, callback) { + callback(null, JSON.parse(this.sessions[sid])) +} + +SyncStore.prototype.set = function set (sid, sess, callback) { + this.sessions[sid] = JSON.stringify(sess) + callback() +} diff --git a/test/support/utils.js b/test/support/utils.js new file mode 100644 index 00000000..0f13ba6a --- /dev/null +++ b/test/support/utils.js @@ -0,0 +1,42 @@ +'use strict' + +module.exports.parseSetCookie = parseSetCookie +module.exports.writePatch = writePatch + +function parseSetCookie (header) { + var match + var pairs = [] + var pattern = /\s*([^=;]+)(?:=([^;]*);?|;|$)/g + + while ((match = pattern.exec(header))) { + pairs.push({ name: match[1], value: match[2] }) + } + + var cookie = pairs.shift() + + for (var i = 0; i < pairs.length; i++) { + match = pairs[i] + cookie[match.name.toLowerCase()] = (match.value || true) + } + + return cookie +} + +function writePatch (res) { + var _end = res.end + var _write = res.write + var ended = false + + res.end = function end () { + ended = true + return _end.apply(this, arguments) + } + + res.write = function write () { + if (ended) { + throw new Error('write after end') + } + + return _write.apply(this, arguments) + } +}