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 3c3629e6..1b6fef23 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,7 @@ +.nyc_output +coverage node_modules +npm-debug.log +package-lock.json +/test/fixtures/server.crt +/test/fixtures/server.key diff --git a/.npmignore b/.npmignore deleted file mode 100644 index cefaa67a..00000000 --- a/.npmignore +++ /dev/null @@ -1,2 +0,0 @@ -test/ -.travis.yml \ No newline at end of file diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 65cf4bc2..00000000 --- a/.travis.yml +++ /dev/null @@ -1,9 +0,0 @@ -language: node_js -node_js: - - "0.8" - - "0.10" - - "0.11" -matrix: - allow_failures: - - node_js: "0.11" - fast_finish: true diff --git a/HISTORY.md b/HISTORY.md new file mode 100644 index 00000000..251d5fa7 --- /dev/null +++ b/HISTORY.md @@ -0,0 +1,489 @@ +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 +=================== + + * Fix mutating `options.secret` value + +1.11.0 / 2015-04-07 +=================== + + * Support an array in `secret` option for key rotation + * deps: depd@~1.0.1 + +1.10.4 / 2015-03-15 +=================== + + * deps: debug@~2.1.3 + - Fix high intensity foreground color for bold + - deps: ms@0.7.0 + +1.10.3 / 2015-02-16 +=================== + + * deps: cookie-signature@1.0.6 + * deps: uid-safe@1.1.0 + - Use `crypto.randomBytes`, if available + - deps: base64-url@1.2.1 + +1.10.2 / 2015-01-31 +=================== + + * deps: uid-safe@1.0.3 + - Fix error branch that would throw + - deps: base64-url@1.2.0 + +1.10.1 / 2015-01-08 +=================== + + * deps: uid-safe@1.0.2 + - Remove dependency on `mz` + +1.10.0 / 2015-01-05 +=================== + + * Add `store.touch` interface for session stores + * Fix `MemoryStore` expiration with `resave: false` + * deps: debug@~2.1.1 + +1.9.3 / 2014-12-02 +================== + + * Fix error when `req.sessionID` contains a non-string value + +1.9.2 / 2014-11-22 +================== + + * deps: crc@3.2.1 + - Minor fixes + +1.9.1 / 2014-10-22 +================== + + * Remove unnecessary empty write call + - Fixes Node.js 0.11.14 behavior change + - Helps work-around Node.js 0.10.1 zlib bug + +1.9.0 / 2014-09-16 +================== + + * deps: debug@~2.1.0 + - Implement `DEBUG_FD` env variable support + * deps: depd@~1.0.0 + +1.8.2 / 2014-09-15 +================== + + * Use `crc` instead of `buffer-crc32` for speed + * deps: depd@0.4.5 + +1.8.1 / 2014-09-08 +================== + + * Keep `req.session.save` non-enumerable + * Prevent session prototype methods from being overwritten + +1.8.0 / 2014-09-07 +================== + + * Do not resave already-saved session at end of request + * deps: cookie-signature@1.0.5 + * deps: debug@~2.0.0 + +1.7.6 / 2014-08-18 +================== + + * Fix exception on `res.end(null)` calls + +1.7.5 / 2014-08-10 +================== + + * Fix parsing original URL + * deps: on-headers@~1.0.0 + * deps: parseurl@~1.3.0 + +1.7.4 / 2014-08-05 +================== + + * Fix response end delay for non-chunked responses + +1.7.3 / 2014-08-05 +================== + + * Fix `res.end` patch to call correct upstream `res.write` + +1.7.2 / 2014-07-27 +================== + + * deps: depd@0.4.4 + - Work-around v8 generating empty stack traces + +1.7.1 / 2014-07-26 +================== + + * deps: depd@0.4.3 + - Fix exception when global `Error.stackTraceLimit` is too low + +1.7.0 / 2014-07-22 +================== + + * Improve session-ending error handling + - Errors are passed to `next(err)` instead of `console.error` + * deps: debug@1.0.4 + * deps: depd@0.4.2 + - Add `TRACE_DEPRECATION` environment variable + - Remove non-standard grey color from color output + - Support `--no-deprecation` argument + - Support `--trace-deprecation` argument + +1.6.5 / 2014-07-11 +================== + + * Do not require `req.originalUrl` + * deps: debug@1.0.3 + - Add support for multiple wildcards in namespaces + +1.6.4 / 2014-07-07 +================== + + * Fix blank responses for stores with synchronous operations + +1.6.3 / 2014-07-04 +================== + + * Fix resave deprecation message + +1.6.2 / 2014-07-04 +================== + + * Fix confusing option deprecation messages + +1.6.1 / 2014-06-28 +================== + + * Fix saveUninitialized deprecation message + +1.6.0 / 2014-06-28 +================== + + * Add deprecation message to undefined `resave` option + * Add deprecation message to undefined `saveUninitialized` option + * Fix `res.end` patch to return correct value + * Fix `res.end` patch to handle multiple `res.end` calls + * Reject cookies with missing signatures + +1.5.2 / 2014-06-26 +================== + + * deps: cookie-signature@1.0.4 + - fix for timing attacks + +1.5.1 / 2014-06-21 +================== + + * Move hard-to-track-down `req.secret` deprecation message + +1.5.0 / 2014-06-19 +================== + + * Debug name is now "express-session" + * Deprecate integration with `cookie-parser` middleware + * Deprecate looking for secret in `req.secret` + * Directly read cookies; `cookie-parser` no longer required + * Directly set cookies; `res.cookie` no longer required + * Generate session IDs with `uid-safe`, faster and even less collisions + +1.4.0 / 2014-06-17 +================== + + * Add `genid` option to generate custom session IDs + * Add `saveUninitialized` option to control saving uninitialized sessions + * Add `unset` option to control unsetting `req.session` + * Generate session IDs with `rand-token` by default; reduce collisions + * deps: buffer-crc32@0.2.3 + +1.3.1 / 2014-06-14 +================== + + * Add description in package for npmjs.org listing + +1.3.0 / 2014-06-14 +================== + + * Integrate with express "trust proxy" by default + * deps: debug@1.0.2 + +1.2.1 / 2014-05-27 +================== + + * Fix `resave` such that `resave: true` works + +1.2.0 / 2014-05-19 +================== + + * Add `resave` option to control saving unmodified sessions + +1.1.0 / 2014-05-12 +================== + + * Add `name` option; replacement for `key` option + * Use `setImmediate` in MemoryStore for node.js >= 0.10 + +1.0.4 / 2014-04-27 +================== + + * deps: debug@0.8.1 + +1.0.3 / 2014-04-19 +================== + + * Use `res.cookie()` instead of `res.setHeader()` + * deps: cookie@0.1.2 + +1.0.2 / 2014-02-23 +================== + + * Add missing dependency to `package.json` + +1.0.1 / 2014-02-15 +================== + + * Add missing dependencies to `package.json` + +1.0.0 / 2014-02-15 +================== + + * Genesis from `connect` diff --git a/History.md b/History.md deleted file mode 100644 index c44ab618..00000000 --- a/History.md +++ /dev/null @@ -1,31 +0,0 @@ -1.1.0 / 2014-05-12 -================== - - * Add `name` option; replacement for `key` option - * Use `setImmediate` in MemoryStore for node.js >= 0.10 - -1.0.4 / 2014-04-27 -================== - - * deps: debug@0.8.1 - -1.0.3 / 2014-04-19 -================== - - * Use `res.cookie()` instead of `res.setHeader()` - * deps: cookie@0.1.2 - -1.0.2 / 2014-02-23 -================== - - * Add missing dependency to `package.json` - -1.0.1 / 2014-02-15 -================== - - * Add missing dependencies to `package.json` - -1.0.0 / 2014-02-15 -================== - - * Genesis from `connect` diff --git a/LICENSE b/LICENSE index a7693b07..9b59ff85 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,8 @@ (The MIT License) -Copyright (c) 2014 TJ Holowaychuk +Copyright (c) 2010 Sencha Inc. +Copyright (c) 2011 TJ Holowaychuk +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 e79f7e87..162ea14d 100644 --- a/README.md +++ b/README.md @@ -1,57 +1,378 @@ -THIS REPOSITORY NEEDS A MAINTAINER. IF YOU'RE INTERESTED IN MAINTAINING THIS REPOSITORY, PLEASE LET US KNOW! +# express-session -# express-session [![Build Status](https://travis-ci.org/expressjs/session.svg?branch=master)](https://travis-ci.org/expressjs/session) [![NPM Version](https://badge.fury.io/js/express-session.svg)](https://badge.fury.io/js/express-session) +[![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] + +## Installation + +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 +``` ## API ```js -var express = require('express') -var cookieParser = require('cookie-parser') -var session = require('express-session') +var session = require('express-session') +``` + +### session(options) + +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 is meant for debugging and +developing. + +For a list of stores, see [compatible session stores](#compatible-session-stores). + +#### Options + +`express-session` accepts these properties in the options object. + +##### cookie + +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). -app.use(cookieParser()) // required before session. -app.use(session({ secret: 'keyboard cat', key: 'sid', cookie: { secure: true }})) +##### 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`. + +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: -### session(options) +```js +var app = express() +var sess = { + secret: 'keyboard cat', + cookie: {} +} -Setup session store with the given `options`. +if (app.get('env') === 'production') { + app.set('trust proxy', 1) // trust first proxy + sess.cookie.secure = true // serve secure cookies +} -Session data is _not_ saved in the cookie itself, however -cookies are used, so we must use the [cookie-parser](https://github.com/expressjs/cookie-parser) -middleware _before_ `session()`. +app.use(session(sess)) +``` -#### Options +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. - - `name` - cookie name. (default: `connect.sid`) - - `store` - session store instance. - - `secret` - session cookie is signed with this secret to prevent tampering. - - `proxy` - trust the reverse proxy when setting secure cookies (via "x-forwarded-proto"). (default: `false`) - - `cookie` - session cookie settings. - - (default: `{ path: '/', httpOnly: true, secure: false, maxAge: null }`) - - `rolling` - forces a cookie reset on response. The reset affects the expiration date. (default: `false`) +##### genid +Function to call to generate a new session ID. Provide a function that returns +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. -#### Cookie options +The default value is a function which uses the `uid-safe` library to generate IDs. -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 for development or other reasons security is not a concern, just use: +**NOTE** be careful to generate unique IDs so your sessions do not conflict. ```js -app.use(cookieParser()) app.use(session({ - secret: 'keyboard cat' - , key: 'sid' - , proxy: true // if you do SSL outside of node. + genid: function(req) { + return genuuid() // use UUIDs for session IDs + }, + secret: 'keyboard cat' })) ``` -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. +##### name + +The name of the session ID cookie to set in the response (and read from in the +request). + +The default value is `'connect.sid'`. + +**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 + +Trust the reverse proxy when setting secure cookies (via the "X-Forwarded-Proto" +header). + +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` 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 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). + +The default value is `true`, but using the default has been deprecated, +as the default will change in the future. Please research into this setting +and choose what is appropriate to your use-case. Typically, you'll want +`false`. + +How do I know if this is necessary for my store? The best way to know is to +check with your store if it implements the `touch` method. If it does, then +you can safely set `resave: false`. If it does not implement the `touch` +method and your store sets an expiration date on stored sessions, then you +likely need `resave: true`. + +##### rolling + +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. Choosing `false` will also +help with race conditions where a client makes multiple parallel requests +without a session. + +The default value is `true`, but using the default has been deprecated, as the +default will change in the future. Please research into this setting and +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. *This has been fixed in PassportJS 0.3.0* + +##### secret + +**Required option** + +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 + +The session store instance, defaults to a new `MemoryStore` instance. + +##### unset + +Control the result of unsetting `req.session` (through `delete`, setting to `null`, +etc.). + +The default value is `'keep'`. + + - `'destroy'` The session will be destroyed (deleted) when the response ends. + - `'keep'` The session in the store will be kept, but modifications made during + the request are ignored and not saved. ### req.session @@ -60,28 +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 -app.use(cookieParser()) +// 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) { @@ -89,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) { @@ -99,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) { @@ -109,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) { @@ -122,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 @@ -144,24 +487,572 @@ 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 + +The `req.session.cookie.originalMaxAge` property returns the original +`maxAge` (time-to-live), in milliseconds, of the session cookie. -Every session store _must_ implement the following methods +### req.sessionID - - `.get(sid, callback)` - - `.set(sid, session, callback)` - - `.destroy(sid, callback)` +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. -Recommended methods include, but are not limited to: +## 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**. - - `.length(callback)` - - `.clear(callback)` + * 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 :) + +[![★][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 + +[![★][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. + +```js +var express = require('express') +var parseurl = require('parseurl') +var session = require('express-session') + +var app = express() + +app.use(session({ + secret: 'keyboard cat', + resave: false, + saveUninitialized: true +})) + +app.use(function (req, res, next) { + if (!req.session.views) { + req.session.views = {} + } + + // get the url pathname + var pathname = parseurl(req).pathname + + // count the views + req.session.views[pathname] = (req.session.views[pathname] || 0) + 1 + + next() +}) + +app.get('/foo', function (req, res, next) { + res.send('you viewed this page ' + req.session.views['/foo'] + ' times') +}) + +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) + +[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 +[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 4d057fa0..3e23e901 100644 --- a/index.js +++ b/index.js @@ -1,25 +1,32 @@ /*! - * Connect - session + * express-session * Copyright(c) 2010 Sencha Inc. * Copyright(c) 2011 TJ Holowaychuk + * Copyright(c) 2014-2015 Douglas Christopher Wilson * MIT Licensed */ +'use strict'; + /** * Module dependencies. + * @private */ -var uid = require('uid2') - , onHeaders = require('on-headers') - , crc32 = require('buffer-crc32') - , parse = require('url').parse - , signature = require('cookie-signature') - , debug = require('debug')('session') - +var Buffer = require('safe-buffer').Buffer +var cookie = require('cookie'); +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 + +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 @@ -42,88 +49,187 @@ exports.MemoryStore = MemoryStore; /** * Warning message for `MemoryStore` usage in production. + * @private */ var warning = 'Warning: connect.session() MemoryStore is not\n' + 'designed for a production environment, as it will leak\n' + 'memory, and will not scale past a single process.'; +/** + * Node.js 0.8+ async implementation. + * @private + */ + +/* istanbul ignore next */ +var defer = typeof setImmediate === 'function' + ? setImmediate + : function(fn){ process.nextTick(fn.bind.apply(fn, arguments)) } + /** * Setup session store with the given `options`. * - * See README.md for documentation of options and formatting. - * - * Session data is _not_ saved in the cookie itself, however cookies are used, - * so you must use the cookie-parser middleware _before_ `session()`. - * [https://github.com/expressjs/cookie-parser] - * - * @param {Object} options + * @param {Object} [options] + * @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] + * @param {Boolean} [options.resave] Resave unmodified sessions back to the store + * @param {Boolean} [options.rolling] Enable/disable rolling session expiration + * @param {Boolean} [options.saveUninitialized] Save uninitialized sessions to the store + * @param {String|Array} [options.secret] Secret for signing session ID + * @param {Object} [options.store=MemoryStore] Session store + * @param {String} [options.unset] * @return {Function} middleware - * @api public + * @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 || false - , storeReady = true - , rollingSessions = options.rolling || false; +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'); + } + + if (resaveSession === undefined) { + deprecate('undefined resave option; provide resave option'); + resaveSession = true; + } + + if (saveUninitializedSession === undefined) { + deprecate('undefined saveUninitialized option; provide saveUninitialized option'); + saveUninitializedSession = true; + } + + 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 = opts.unset === 'destroy' + + if (Array.isArray(secret) && secret.length === 0) { + throw new TypeError('secret option array must contain one or more strings'); + } + + if (secret && !Array.isArray(secret)) { + secret = [secret]; + } + + if (!secret) { + deprecate('req.secret; provide secret option'); + } // 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); } // generates the new session store.generate = function(req){ - req.sessionID = uid(24); + 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'; + } }; - store.on('disconnect', function(){ storeReady = false; }); - store.on('connect', function(){ storeReady = true; }); + var storeImplementsTouch = typeof store.touch === 'function'; + + // 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 = parse(req.originalUrl).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) { + next(new Error('secret option required for sessions')); + return; + } // backwards compatibility for signed cookies // req.secret is passed from the cookie parser middleware - var secret = options.secret || req.secret; + var secrets = secret || [req.secret]; - // ensure secret is available or bail - if (!secret) throw new Error('`secret` option required for sessions'); - - var originalHash - , originalId; + var originalHash; + var originalId; + var savedHash; + var touched = false // expose store req.sessionStore = store; - // grab the session cookie value and check the signature - var rawCookie = req.cookies[name]; - - // get signedCookies for backwards compat with signed cookies - var unsignedCookie = req.signedCookies[name]; - - if (!unsignedCookie && rawCookie) { - unsignedCookie = (0 == rawCookie.indexOf('s:')) - ? signature.unsign(rawCookie.slice(2), secret) - : rawCookie; - } + // get the session ID from the cookie + var cookieId = req.sessionID = getcookie(req, name, secrets); // set-cookie onHeaders(res, function(){ @@ -132,60 +238,258 @@ function session(options){ return; } - var cookie = req.session.cookie - , proto = (req.headers['x-forwarded-proto'] || '').split(',')[0].toLowerCase().trim() - , tls = req.connection.encrypted || (trustProxy && 'https' == proto) - , isNew = unsignedCookie != req.sessionID; + if (!shouldSetCookie(req)) { + return; + } // only send secure cookies via https - if (cookie.secure && !tls) { + if (req.session.cookie.secure && !issecure(req, trustProxy)) { debug('not secured'); return; } - // in case of rolling session, always reset the cookie - if (!rollingSessions) { + if (!touched) { + // touch session + req.session.touch() + touched = true + } + + // 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 + var _end = res.end; + var _write = res.write; + var ended = false; + res.end = function end(chunk, encoding) { + if (ended) { + return false; + } + + ended = true; + + var ret; + var sync = true; + + function writeend() { + if (sync) { + ret = _end.call(res, chunk, encoding); + sync = false; + return; + } + + _end.call(res); + } + + function writetop() { + if (!sync) { + return ret; + } + + if (!res._header) { + res._implicitHeader() + } - // browser-session length cookie - if (null == cookie.expires) { - if (!isNew) { - debug('already set browser-session cookie'); - return + if (chunk == null) { + ret = true; + return ret; + } + + var contentLength = Number(res.getHeader('Content-Length')); + + if (!isNaN(contentLength) && contentLength > 0) { + // measure chunk + chunk = !Buffer.isBuffer(chunk) + ? Buffer.from(chunk, encoding) + : chunk; + encoding = undefined; + + if (chunk.length !== 0) { + debug('split response'); + ret = _write.call(res, chunk.slice(0, chunk.length - 1)); + chunk = chunk.slice(chunk.length - 1, chunk.length); + return ret; } - // compare hashes and ids - } else if (originalHash == hash(req.session) && originalId == req.session.id) { - debug('unmodified session'); - return } + ret = _write.call(res, chunk, encoding); + sync = false; + + return ret; } - var val = 's:' + signature.sign(req.sessionID, secret); - debug('set-cookie %s', val); - res.cookie(name, val, cookie.data); - }); + if (shouldDestroy(req)) { + // destroy session + debug('destroying'); + store.destroy(req.sessionID, function ondestroy(err) { + if (err) { + defer(next, err); + } - // proxy end() to commit the session - var end = res.end; - res.end = function(data, encoding){ - res.end = end; - if (!req.session) return res.end(data, encoding); - debug('saving'); - req.session.resetMaxAge(); - req.session.save(function(err){ - if (err) console.error(err.stack); - debug('saved'); - res.end(data, encoding); - }); + debug('destroyed'); + writeend(); + }); + + return writetop(); + } + + // no session to save + if (!req.session) { + debug('no session'); + return _end.call(res, chunk, encoding); + } + + if (!touched) { + // touch session + req.session.touch() + touched = true + } + + if (shouldSave(req)) { + req.session.save(function onsave(err) { + if (err) { + defer(next, err); + } + + writeend(); + }); + + return writetop(); + } else if (storeImplementsTouch && shouldTouch(req)) { + // store implements touch method + debug('touching'); + store.touch(req.sessionID, req.session, function ontouch(err) { + if (err) { + defer(next, err); + } + + debug('touched'); + writeend(); + }); + + return writetop(); + } + + return _end.call(res, chunk, encoding); }; // generate the session function generate() { store.generate(req); + originalId = req.sessionID; + originalHash = hash(req.session); + 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, + value: save, + writable: true + }); + } + + // check if session has been modified + function isModified(sess) { + return originalId !== sess.id || originalHash !== hash(sess); + } + + // check if session has been saved + function isSaved(sess) { + return originalId === sess.id && savedHash === hash(sess); + } + + // determine if session should be destroyed + function shouldDestroy(req) { + return req.sessionID && unsetDestroy && req.session == null; } - // get the sessionID from the cookie - req.sessionID = unsignedCookie; + // determine if session should be saved to store + function shouldSave(req) { + // cannot set cookie without a session ID + if (typeof req.sessionID !== 'string') { + debug('session ignored because of bogus req.sessionID %o', req.sessionID); + return false; + } + + return !saveUninitializedSession && !savedHash && cookieId !== req.sessionID + ? isModified(req.session) + : !isSaved(req.session) + } + + // determine if session should be touched + function shouldTouch(req) { + // cannot set cookie without a session ID + if (typeof req.sessionID !== 'string') { + debug('session ignored because of bogus req.sessionID %o', req.sessionID); + return false; + } + + return cookieId === req.sessionID && !shouldSave(req); + } + + // determine if cookie should be set on response + function shouldSetCookie(req) { + // cannot set cookie without a session ID + if (typeof req.sessionID !== 'string') { + return false; + } + + return cookieId !== req.sessionID + ? saveUninitializedSession || isModified(req.session) + : rollingSessions || req.session.cookie.expires != null && isModified(req.session); + } // generate a session if the browser doesn't send a sessionID if (!req.sessionID) { @@ -199,41 +503,239 @@ 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); - if ('ENOENT' == err.code) { - generate(); - next(); + next(err) + return + } + + try { + if (err || !sess) { + debug('no session found') + generate() } else { - next(err); + debug('session found') + inflate(req, sess) } - // no session - } else if (!sess) { - debug('no session found'); - generate(); - next(); - // populate req.session - } else { - debug('session found'); - store.createSession(req, sess); - originalId = req.sessionID; - originalHash = hash(sess); - next(); + } catch (e) { + next(e) + return } + + 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. + * + * @return {String} + * @private + */ + +function generateSessionId() { + return uid(24); +} + +/** + * Get the session ID cookie from request. + * + * @return {string} + * @private + */ + +function getcookie(req, name, secrets) { + var header = req.headers.cookie; + var raw; + var val; + + // read from cookie header + if (header) { + var cookies = cookie.parse(header); + + raw = cookies[name]; + + if (raw) { + if (raw.slice(0, 2) === 's:') { + val = unsigncookie(raw.slice(2), secrets); + + if (val === false) { + debug('cookie signature invalid'); + val = undefined; + } + } else { + debug('cookie unsigned') + } + } + } + + // back-compat read from cookieParser() signedCookies data + if (!val && req.signedCookies) { + val = req.signedCookies[name]; + + if (val) { + deprecate('cookie should be available in req.headers.cookie'); + } + } + + // back-compat read from cookieParser() cookies data + if (!val && req.cookies) { + raw = req.cookies[name]; + + if (raw) { + if (raw.slice(0, 2) === 's:') { + val = unsigncookie(raw.slice(2), secrets); + + if (val) { + deprecate('cookie should be available in req.headers.cookie'); + } + + if (val === false) { + debug('cookie signature invalid'); + val = undefined; + } + } else { + debug('cookie unsigned') + } + } + } + + return val; +} + /** * Hash the given `sess` object omitting changes to `.cookie`. * * @param {Object} sess * @return {String} - * @api private + * @private */ function hash(sess) { - return crc32.signed(JSON.stringify(sess, function(key, val){ - if ('cookie' != key) 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') +} + +/** + * Determine if request is secure. + * + * @param {Object} req + * @param {Boolean} [trustProxy] + * @return {Boolean} + * @private + */ + +function issecure(req, trustProxy) { + // socket is https server + var socket = req.socket || req.connection; + if (socket && socket.encrypted) { + return true; + } + + // do not trust proxy + if (trustProxy === false) { + return false; + } + + // no explicit trust; try req.secure from express + if (trustProxy !== true) { + 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.slice(0, index).toLowerCase().trim() + : header.toLowerCase().trim() + + return proto === 'https'; +} + +/** + * Set cookie on response. + * + * @private + */ + +function setcookie(res, name, val, secret, options) { + var signed = 's:' + signature.sign(val, secret); + var data = cookie.serialize(name, signed, options); + + debug('set-cookie %s', data); + + var prev = res.getHeader('Set-Cookie') || [] + var header = Array.isArray(prev) ? prev.concat(data) : [prev, data]; + + res.setHeader('Set-Cookie', header) +} + +/** + * Verify and decode the given `val` with `secrets`. + * + * @param {String} val + * @param {Array} secrets + * @returns {String|Boolean} + * @private + */ +function unsigncookie(val, secrets) { + for (var i = 0; i < secrets.length; i++) { + var result = signature.unsign(val, secrets[i]); + + if (result !== false) { + return result; + } + } + + return false; } diff --git a/package.json b/package.json index b6367894..36387796 100644 --- a/package.json +++ b/package.json @@ -1,33 +1,50 @@ { "name": "express-session", - "version": "1.1.0", + "version": "1.19.0", + "description": "Simple session middleware for Express", "author": "TJ Holowaychuk (http://tjholowaychuk.com)", - "main": "./index.js", - "repository": { - "type": "git", - "url": "git://github.com/expressjs/session.git" + "contributors": [ + "Douglas Christopher Wilson ", + "Joe Wagner " + ], + "repository": "expressjs/session", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" }, + "license": "MIT", "dependencies": { - "utils-merge": "1.0.0", - "cookie": "0.1.2", - "cookie-signature": "1.0.3", - "on-headers": "0.0.0", - "uid2": "0.0.3", - "buffer-crc32": "0.2.1", - "debug": "0.8.1" + "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": { - "express": "4.0.0", - "mocha": "~1.18.2", - "should": "~3.3.1", - "supertest": "~0.12.1", - "cookie-parser": "1.0.1" - }, - "scripts": { - "test": "mocha --bail --ui bdd --reporter spec -- test/*.js" + "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/", + "index.js" + ], "engines": { "node": ">= 0.8.0" }, - "license": "MIT" + "scripts": { + "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 9720b061..11ed686c 100644 --- a/session/memory.js +++ b/session/memory.js @@ -1,137 +1,187 @@ - /*! - * Connect - session - MemoryStore + * express-session * Copyright(c) 2010 Sencha Inc. * Copyright(c) 2011 TJ Holowaychuk + * Copyright(c) 2015 Douglas Christopher Wilson * MIT Licensed */ +'use strict'; + /** * Module dependencies. + * @private */ -var Store = require('./store'); +var Store = require('./store') +var util = require('util') /** * Shim setImmediate for node.js < 0.10 + * @private */ -var asyncTick = typeof setImmediate === 'function' +/* istanbul ignore next */ +var defer = typeof setImmediate === 'function' ? setImmediate - : process.nextTick; + : function(fn){ process.nextTick(fn.bind.apply(fn, arguments)) } /** - * Initialize a new `MemoryStore`. - * - * @api public + * Module exports. */ -var MemoryStore = module.exports = function MemoryStore() { - this.sessions = {}; -}; +module.exports = MemoryStore /** - * Inherit from `Store.prototype`. + * A session store in memory. + * @public */ -MemoryStore.prototype.__proto__ = Store.prototype; +function MemoryStore() { + Store.call(this) + this.sessions = Object.create(null) +} /** - * Attempt to fetch session by the given `sid`. + * Inherit from Store. + */ + +util.inherits(MemoryStore, Store) + +/** + * Get all active sessions. * - * @param {String} sid - * @param {Function} fn - * @api public + * @param {function} callback + * @public */ -MemoryStore.prototype.get = function(sid, fn){ - var self = this; - asyncTick(function(){ - var expires - , sess = self.sessions[sid]; - if (sess) { - sess = JSON.parse(sess); - expires = 'string' == typeof sess.cookie.expires - ? new Date(sess.cookie.expires) - : sess.cookie.expires; - if (!expires || new Date < expires) { - fn(null, sess); - } else { - self.destroy(sid, fn); - } - } else { - fn(); +MemoryStore.prototype.all = function all(callback) { + var sessionIds = Object.keys(this.sessions) + var sessions = Object.create(null) + + for (var i = 0; i < sessionIds.length; i++) { + var sessionId = sessionIds[i] + var session = getSession.call(this, sessionId) + + if (session) { + sessions[sessionId] = session; } - }); -}; + } + + callback && defer(callback, null, sessions) +} /** - * Commit the given `sess` object associated with the given `sid`. + * Clear all sessions. * - * @param {String} sid - * @param {Session} sess - * @param {Function} fn - * @api public + * @param {function} callback + * @public */ -MemoryStore.prototype.set = function(sid, sess, fn){ - var self = this; - asyncTick(function(){ - self.sessions[sid] = JSON.stringify(sess); - fn && fn(); - }); -}; +MemoryStore.prototype.clear = function clear(callback) { + this.sessions = Object.create(null) + callback && defer(callback) +} /** - * Destroy the session associated with the given `sid`. + * Destroy the session associated with the given session ID. * - * @param {String} sid - * @api public + * @param {string} sessionId + * @public */ -MemoryStore.prototype.destroy = function(sid, fn){ - var self = this; - asyncTick(function(){ - delete self.sessions[sid]; - fn && fn(); - }); -}; +MemoryStore.prototype.destroy = function destroy(sessionId, callback) { + delete this.sessions[sessionId] + callback && defer(callback) +} /** - * Invoke the given callback `fn` with all active sessions. + * Fetch session by the given session ID. * - * @param {Function} fn - * @api public + * @param {string} sessionId + * @param {function} callback + * @public */ -MemoryStore.prototype.all = function(fn){ - var arr = [] - , keys = Object.keys(this.sessions); - for (var i = 0, len = keys.length; i < len; ++i) { - arr.push(this.sessions[keys[i]]); - } - fn(null, arr); -}; +MemoryStore.prototype.get = function get(sessionId, callback) { + defer(callback, null, getSession.call(this, sessionId)) +} /** - * Clear all sessions. + * Commit the given session associated with the given sessionId to the store. + * + * @param {string} sessionId + * @param {object} session + * @param {function} callback + * @public + */ + +MemoryStore.prototype.set = function set(sessionId, session, callback) { + this.sessions[sessionId] = JSON.stringify(session) + callback && defer(callback) +} + +/** + * Get number of active sessions. * - * @param {Function} fn - * @api public + * @param {function} callback + * @public */ -MemoryStore.prototype.clear = function(fn){ - this.sessions = {}; - fn && fn(); -}; +MemoryStore.prototype.length = function length(callback) { + this.all(function (err, sessions) { + if (err) return callback(err) + callback(null, Object.keys(sessions).length) + }) +} /** - * Fetch number of sessions. + * Touch the given session object associated with the given session ID. * - * @param {Function} fn - * @api public + * @param {string} sessionId + * @param {object} session + * @param {function} callback + * @public + */ + +MemoryStore.prototype.touch = function touch(sessionId, session, callback) { + var currentSession = getSession.call(this, sessionId) + + if (currentSession) { + // update expiration + currentSession.cookie = session.cookie + this.sessions[sessionId] = JSON.stringify(currentSession) + } + + callback && defer(callback) +} + +/** + * Get session from the store. + * @private */ -MemoryStore.prototype.length = function(fn){ - fn(null, Object.keys(this.sessions).length); -}; +function getSession(sessionId) { + var sess = this.sessions[sessionId] + + if (!sess) { + return + } + + // parse + sess = JSON.parse(sess) + + 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 + } + } + + return sess +} diff --git a/session/session.js b/session/session.js index 891f31c7..fee7608c 100644 --- a/session/session.js +++ b/session/session.js @@ -1,4 +1,3 @@ - /*! * Connect - session - Session * Copyright(c) 2010 Sencha Inc. @@ -6,11 +5,13 @@ * MIT Licensed */ +'use strict'; + /** - * Module dependencies. + * Expose Session. */ -var merge = require('utils-merge') +module.exports = Session; /** * Create a new `Session` with the given request and `data`. @@ -20,11 +21,19 @@ var merge = require('utils-merge') * @api private */ -var Session = module.exports = function Session(req, data) { +function Session(req, data) { Object.defineProperty(this, 'req', { value: req }); Object.defineProperty(this, 'id', { value: req.sessionID }); - if ('object' == typeof data) merge(this, data); -}; + + if (typeof data === 'object' && data !== null) { + // merge data into this, ignoring prototype properties + for (var prop in data) { + if (!(prop in this)) { + this[prop] = data[prop] + } + } + } +} /** * Update reset `.cookie.maxAge` to prevent @@ -35,9 +44,9 @@ var Session = module.exports = function Session(req, data) { * @api public */ -Session.prototype.touch = function(){ +defineMethod(Session.prototype, 'touch', function touch() { return this.resetMaxAge(); -}; +}); /** * Reset `.maxAge` to `.originalMaxAge`. @@ -46,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)`. @@ -59,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 @@ -76,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')); @@ -86,7 +96,7 @@ Session.prototype.reload = function(fn){ fn(); }); return this; -}; +}); /** * Destroy `this` session. @@ -96,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. @@ -110,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/session.js b/test/session.js index 7e05b024..405e3ddf 100644 --- a/test/session.js +++ b/test/session.js @@ -1,665 +1,2990 @@ +var after = require('after') +var assert = require('assert') +var cookieParser = require('cookie-parser') +var crypto = require('crypto') var express = require('express') - , assert = require('assert') - , request = require('supertest') - , should = require('should') - , 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; -function respond(req, res) { - res.end(); -} +describe('session()', function(){ + it('should export constructors', function(){ + assert.strictEqual(typeof session.Session, 'function') + assert.strictEqual(typeof session.Store, 'function') + assert.strictEqual(typeof session.MemoryStore, 'function') + }) -function sid(res) { - var val = res.headers['set-cookie']; - if (!val) return ''; - return /^connect\.sid=([^;]+);/.exec(val[0])[1]; -} + it('should do nothing if req.session exists', function(done){ + function setup (req) { + req.session = {} + } -function expires(res) { - return res.headers['set-cookie'][0].match(/Expires=([^;]+)/)[1]; -} + request(createServer(setup)) + .get('/') + .expect(shouldNotHaveHeader('Set-Cookie')) + .expect(200, done) + }) -var app = express() - .use(cookieParser()) - .use(session({ secret: 'keyboard cat', cookie: { maxAge: min }})) - .use(respond); + it('should error without secret', function(done){ + request(createServer({ secret: undefined })) + .get('/') + .expect(500, /secret.*required/, done) + }) -describe('session()', function(){ - it('should export constructors', function(){ - session.Session.should.be.a.Function; - session.Store.should.be.a.Function; - session.MemoryStore.should.be.a.Function; + it('should get secret from req.secret', function(done){ + function setup (req) { + req.secret = 'keyboard cat' + } + + request(createServer(setup, { secret: undefined })) + .get('/') + .expect(200, '', done) + }) + + it('should create a new session', function (done) { + var store = new session.MemoryStore() + var server = createServer({ store: store }, function (req, res) { + req.session.active = true + res.end('session active') + }); + + request(server) + .get('/') + .expect(shouldSetCookie('connect.sid')) + .expect(200, 'session active', function (err, res) { + if (err) return done(err) + store.length(function (err, len) { + if (err) return done(err) + assert.strictEqual(len, 1) + done() + }) + }) + }) + + it('should load session from cookie sid', function (done) { + var count = 0 + var server = createServer(null, 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) + request(server) + .get('/') + .set('Cookie', cookie(res)) + .expect(200, 'session 1', done) + }) + }) + + it('should pass session fetch error', function (done) { + var store = new session.MemoryStore() + var server = createServer({ store: store }, function (req, res) { + res.end('hello, world') + }) + + store.get = function destroy(sid, callback) { + callback(new Error('boom!')) + } + + request(server) + .get('/') + .expect(shouldSetCookie('connect.sid')) + .expect(200, 'hello, world', function (err, res) { + if (err) return done(err) + request(server) + .get('/') + .set('Cookie', cookie(res)) + .expect(500, 'boom!', done) + }) + }) + + it('should treat ENOENT session fetch error as not found', 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) + }) + + store.get = function destroy(sid, callback) { + var err = new Error('boom!') + err.code = 'ENOENT' + callback(err) + } + + request(server) + .get('/') + .expect(shouldSetCookie('connect.sid')) + .expect(200, 'session 1', function (err, res) { + if (err) return done(err) + request(server) + .get('/') + .set('Cookie', cookie(res)) + .expect(200, 'session 2', done) + }) + }) + + it('should create multiple sessions', function (done) { + var cb = after(2, check) + var count = 0 + var store = new session.MemoryStore() + var server = createServer({ store: store }, function (req, res) { + var isnew = req.session.num === undefined + req.session.num = req.session.num || ++count + res.end('session ' + (isnew ? 'created' : 'updated')) + }); + + function check(err) { + if (err) return done(err) + store.all(function (err, sess) { + if (err) return done(err) + assert.strictEqual(Object.keys(sess).length, 2) + done() + }) + } + + request(server) + .get('/') + .expect(200, 'session created', cb) + + request(server) + .get('/') + .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 server = createServer(null, function (req, res) { + res.setHeader('Content-Type', 'text/plain') + res.end('Hello, world!') + res.end() + }) + + request(server) + .get('/') + .expect('Content-Type', 'text/plain') + .expect(200, 'Hello, world!', done); + }) + + it('should handle res.end(null) calls', function (done) { + var server = createServer(null, function (req, res) { + res.end(null) + }) + + request(server) + .get('/') + .expect(200, '', done) + }) + + it('should handle reserved properties in storage', function (done) { + var count = 0 + var sid + var store = new session.MemoryStore() + var server = createServer({ store: store }, function (req, res) { + sid = req.session.id + req.session.num = req.session.num || ++count + res.end('session saved') + }) + + request(server) + .get('/') + .expect(200, 'session saved', function (err, res) { + if (err) return done(err) + store.get(sid, function (err, sess) { + if (err) return done(err) + // save is reserved + sess.save = 'nope' + store.set(sid, sess, function (err) { + if (err) return done(err) + request(server) + .get('/') + .set('Cookie', cookie(res)) + .expect(200, 'session saved', done) + }) + }) + }) + }) + + it('should only have session data enumerable (and cookie)', function (done) { + var server = createServer(null, function (req, res) { + req.session.test1 = 1 + req.session.test2 = 'b' + res.end(Object.keys(req.session).sort().join(',')) + }) + + request(server) + .get('/') + .expect(200, 'cookie,test1,test2', done) + }) + + it('should not save with bogus req.sessionID', function (done) { + var store = new session.MemoryStore() + var server = createServer({ store: store }, function (req, res) { + req.sessionID = function () {} + req.session.test1 = 1 + req.session.test2 = 'b' + res.end() + }) + + request(server) + .get('/') + .expect(shouldNotHaveHeader('Set-Cookie')) + .expect(200, function (err) { + if (err) return done(err) + store.length(function (err, length) { + if (err) return done(err) + 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 store = new session.MemoryStore() + var server = createServer({ store: store }, function (req, res) { + req.session.hit = true + res.end('session saved') + }) + + request(server) + .get('/') + .expect(200) + .expect(shouldSetSessionInStore(store, 200)) + .expect('session saved') + .end(done) + }) + + it('should have saved session even with empty response', function (done) { + var store = new session.MemoryStore() + var server = createServer({ store: store }, function (req, res) { + req.session.hit = true + res.setHeader('Content-Length', '0') + res.end() + }) + + request(server) + .get('/') + .expect(200) + .expect(shouldSetSessionInStore(store, 200)) + .end(done) + }) + + it('should have saved session even with multi-write', function (done) { + var store = new session.MemoryStore() + var server = createServer({ store: store }, function (req, res) { + req.session.hit = true + res.setHeader('Content-Length', '12') + res.write('hello, ') + res.end('world') + }) + + request(server) + .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 store = new session.MemoryStore() + var server = createServer({ store: store }, function (req, res) { + req.session.hit = true + res.setHeader('Content-Length', '13') + res.end('session saved') + }) + + 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(shouldSetCookie('connect.sid')) + .expect(200, function (err, res) { + if (err) return done(err) + 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)) + }) + }) + }) + }) + + describe('when sid not in store', function () { + it('should create a new session', 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.clear(function (err) { + if (err) return done(err) + request(server) + .get('/') + .set('Cookie', cookie(res)) + .expect(200, 'session 2', done) + }) + }) + }) + + it('should have a new sid', 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.clear(function (err) { + if (err) return done(err) + request(server) + .get('/') + .set('Cookie', cookie(res)) + .expect(shouldSetCookie('connect.sid')) + .expect(shouldSetCookieToDifferentSessionId(sid(res))) + .expect(200, 'session 2', done) + }) + }) + }) + }) + + describe('when sid not properly signed', function () { + it('should generate new session', function (done) { + var store = new session.MemoryStore() + var server = createServer({ store: store, key: 'sessid' }, function (req, res) { + var isnew = req.session.active === undefined + req.session.active = true + res.end('session ' + (isnew ? 'created' : 'read')) + }) + + request(server) + .get('/') + .expect(shouldSetCookie('sessid')) + .expect(200, 'session created', function (err, res) { + if (err) return done(err) + var val = sid(res) + assert.ok(val) + request(server) + .get('/') + .set('Cookie', 'sessid=' + val) + .expect(shouldSetCookie('sessid')) + .expect(shouldSetCookieToDifferentSessionId(val)) + .expect(200, 'session created', done) + }) + }) + + it('should not attempt fetch from store', function (done) { + var store = new session.MemoryStore() + var server = createServer({ store: store, key: 'sessid' }, function (req, res) { + var isnew = req.session.active === undefined + req.session.active = true + res.end('session ' + (isnew ? 'created' : 'read')) + }) + + request(server) + .get('/') + .expect(shouldSetCookie('sessid')) + .expect(200, 'session created', function (err, res) { + if (err) return done(err) + var val = cookie(res).replace(/...\./, '.') + + assert.ok(val) + request(server) + .get('/') + .set('Cookie', val) + .expect(shouldSetCookie('sessid')) + .expect(200, 'session created', done) + }) + }) + }) + + describe('when session expired in store', function () { + it('should create a new session', function (done) { + var count = 0 + var store = new session.MemoryStore() + var server = createServer({ store: store, cookie: { maxAge: 5 } }, 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) + setTimeout(function () { + request(server) + .get('/') + .set('Cookie', cookie(res)) + .expect(shouldSetCookie('connect.sid')) + .expect(200, 'session 2', done) + }, 20) + }) + }) + + it('should have a new sid', function (done) { + var count = 0 + var store = new session.MemoryStore() + var server = createServer({ store: store, cookie: { maxAge: 5 } }, 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) + setTimeout(function () { + request(server) + .get('/') + .set('Cookie', cookie(res)) + .expect(shouldSetCookie('connect.sid')) + .expect(shouldSetCookieToDifferentSessionId(sid(res))) + .expect(200, 'session 2', done) + }, 15) + }) + }) + + it('should not exist in store', function (done) { + var count = 0 + var store = new session.MemoryStore() + var server = createServer({ store: store, cookie: { maxAge: 5 } }, 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) + setTimeout(function () { + store.all(function (err, sess) { + if (err) return done(err) + assert.strictEqual(Object.keys(sess).length, 0) + done() + }) + }, 10) + }) + }) + }) + + 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(){ - it('should trust X-Forwarded-Proto when string', function(done){ - var app = express() - .use(cookieParser()) - .use(session({ secret: 'keyboard cat', proxy: true, cookie: { secure: true, maxAge: 5 }})) - .use(respond); + var server + before(function () { + server = createServer({ proxy: true, cookie: { secure: true, maxAge: 5 }}) + }) - request(app) + it('should trust X-Forwarded-Proto when string', function(done){ + request(server) .get('/') .set('X-Forwarded-Proto', 'https') - .end(function(err, res){ - res.headers.should.have.property('set-cookie'); - done(); - }); + .expect(shouldSetCookie('connect.sid')) + .expect(200, done) }) it('should trust X-Forwarded-Proto when comma-separated list', function(done){ - var app = express() - .use(cookieParser()) - .use(session({ secret: 'keyboard cat', proxy: true, cookie: { secure: true, maxAge: 5 }})) - .use(respond); - - request(app) + request(server) .get('/') .set('X-Forwarded-Proto', 'https,http') - .end(function(err, res){ - res.headers.should.have.property('set-cookie'); - done(); - }); + .expect(shouldSetCookie('connect.sid')) + .expect(200, done) + }) + + it('should work when no header', function(done){ + request(server) + .get('/') + .expect(shouldNotHaveHeader('Set-Cookie')) + .expect(200, done) }) }) describe('when disabled', function(){ + before(function () { + 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){ - var app = express() - .use(cookieParser()) - .use(session({ secret: 'keyboard cat', cookie: { secure: true, maxAge: min }})) - .use(respond); + request(this.server) + .get('/') + .set('X-Forwarded-Proto', 'https') + .expect(shouldNotHaveHeader('Set-Cookie')) + .expect(200, done) + }) - request(app) + it('should ignore req.secure', function (done) { + request(this.server) .get('/') .set('X-Forwarded-Proto', 'https') - .end(function(err, res){ - res.headers.should.not.have.property('set-cookie'); - done(); - }); + .set('X-Secure', 'true') + .expect(shouldNotHaveHeader('Set-Cookie')) + .expect(200, 'true', done) }) }) - }) - describe('key option', function(){ - it('should default to "connect.sid"', function(done){ - request(app) - .get('/') - .end(function(err, res){ - res.headers['set-cookie'].should.have.length(1); - res.headers['set-cookie'][0].should.match(/^connect\.sid/); - done(); - }); - }) + describe('when unspecified', function(){ + before(function () { + function setup (req) { + req.secure = req.headers['x-secure'] + ? JSON.parse(req.headers['x-secure']) + : undefined + } - it('should allow overriding', function(done){ - var app = express() - .use(cookieParser()) - .use(session({ secret: 'keyboard cat', key: 'sid', cookie: { maxAge: min }})) - .use(respond); + function respond (req, res) { + res.end(String(req.secure)) + } - request(app) - .get('/') - .end(function(err, res){ - res.headers['set-cookie'].should.have.length(1); - res.headers['set-cookie'][0].should.match(/^sid/); - done(); - }); + this.server = createServer(setup, { cookie: { secure: true }}, respond) + }) + + it('should not trust X-Forwarded-Proto', function(done){ + request(this.server) + .get('/') + .set('X-Forwarded-Proto', 'https') + .expect(shouldNotHaveHeader('Set-Cookie')) + .expect(200, done) + }) + + 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) + }) }) }) - it('should retain the sid', function(done){ - var n = 0; + describe('cookie option', function () { + describe('when "path" set to "/foo/bar"', function () { + before(function () { + this.server = createServer({ cookie: { path: '/foo/bar' } }) + }) + + it('should not set cookie for "/" request', function (done) { + request(this.server) + .get('/') + .expect(shouldNotHaveHeader('Set-Cookie')) + .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 set cookie for "/foo/bar" request', function (done) { + request(this.server) + .get('/foo/bar/baz') + .expect(shouldSetCookie('connect.sid')) + .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) + }) + + describe('when mounted at "/foo"', function () { + before(function () { + this.server = createServer(mountAt('/foo'), { cookie: { path: '/foo/bar' } }) + }) + + it('should set cookie for "/foo/bar" request', function (done) { + request(this.server) + .get('/foo/bar') + .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) + }) + }) + }) + + 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' }}) + }) + + 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) + }) + }) + + describe('when "proxy" is "false"', function () { + before(function () { + this.server = createServer({ proxy: false, cookie: { maxAge: 5, secure: 'auto' }}) + }) + + 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) + }) + }) + + describe('when "proxy" is undefined', 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' } }, 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 not set cookie when request-path does not match returned path', function (done) { + var cookieCallback = function () { + return { path: '/admin', httpOnly: true, secure: false }; + }; + var server = createServer({ cookie: cookieCallback }); + request(server) + .get('/administrator') + .expect(shouldNotHaveHeader('Set-Cookie')) + .expect(200, done); + }); + + 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(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(server) + .get('/') + .set('Cookie', cookie(res)) + .expect(shouldNotHaveHeader('Set-Cookie')) + .expect(200, done) + }); + }); + + it('should force cookie on unmodified session', function(done){ + var server = createServer({ rolling: true }, 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(server) + .get('/') + .set('Cookie', cookie(res)) + .expect(shouldSetCookie('connect.sid')) + .expect(200, done) + }); + }); + + 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(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(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(server) + .get('/') + .set('Cookie', cookie(res)) + .expect(shouldSetSessionInStore(store)) + .expect(200, done); + }); + }); + + 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) + }) + }) + }) + + 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() + }) + + 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) + }) + }) + + 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('/') + .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 store = new session.MemoryStore() + var server = createServer({ store: store }) + + request(server) + .get('/') + .expect(shouldSetSessionInStore(store)) + .expect(shouldSetCookie('connect.sid')) + .expect(200, done); + }); + + it('should force save of uninitialized session', function(done){ + var store = new session.MemoryStore() + var server = createServer({ store: store, saveUninitialized: true }) + + request(server) + .get('/') + .expect(shouldSetSessionInStore(store)) + .expect(shouldSetCookie('connect.sid')) + .expect(200, done); + }); + + it('should prevent save of uninitialized session', function(done){ + var store = new session.MemoryStore() + var server = createServer({ store: store, saveUninitialized: false }) + + request(server) + .get('/') + .expect(shouldNotSetSessionInStore(store)) + .expect(shouldNotHaveHeader('Set-Cookie')) + .expect(200, done) + }); + + it('should still save modified session', function(done){ + 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(server) + .get('/') + .expect(shouldSetSessionInStore(store)) + .expect(shouldSetCookie('connect.sid')) + .expect(200, done); + }); + + it('should pass session save error', function (done) { + var cb = after(2, done) + var store = new session.MemoryStore() + var server = createServer({ store: store, saveUninitialized: true }, function (req, res) { + res.end('session saved') + }) + + store.set = function destroy(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('/') + .expect(200, 'session saved', cb) + }) + + it('should prevent uninitialized session from being touched', function (done) { + var cb = after(1, done) + var store = new session.MemoryStore() + var server = createServer({ saveUninitialized: false, store: store, cookie: { maxAge: min } }, function (req, res) { + res.end() + }) + + store.touch = function () { + cb(new Error('should not be called')) + } + + request(server) + .get('/') + .expect(200, cb) + }) + }); + + describe('secret option', function () { + it('should reject empty arrays', 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) { + req.session.user = 'bob'; + res.end(req.session.user); + }); + + request(server) + .get('/') + .expect(shouldSetCookie('connect.sid')) + .expect(200, 'bob', done); + }) + + it('should sign cookies with first element', function (done) { + var store = new session.MemoryStore(); + + var server1 = createServer({ secret: ['keyboard cat', 'nyan cat'], store: store }, function (req, res) { + req.session.user = 'bob'; + res.end(req.session.user); + }); + + var server2 = createServer({ secret: 'nyan cat', store: store }, function (req, res) { + res.end(String(req.session.user)); + }); + + request(server1) + .get('/') + .expect(shouldSetCookie('connect.sid')) + .expect(200, 'bob', function (err, res) { + if (err) return done(err); + request(server2) + .get('/') + .set('Cookie', cookie(res)) + .expect(200, 'undefined', done); + }); + }); + + it('should read cookies using all elements', function (done) { + var store = new session.MemoryStore(); + + var server1 = createServer({ secret: 'nyan cat', store: store }, function (req, res) { + req.session.user = 'bob'; + res.end(req.session.user); + }); + + var server2 = createServer({ secret: ['keyboard cat', 'nyan cat'], store: store }, function (req, res) { + res.end(String(req.session.user)); + }); + + request(server1) + .get('/') + .expect(shouldSetCookie('connect.sid')) + .expect(200, 'bob', function (err, res) { + if (err) return done(err); + request(server2) + .get('/') + .set('Cookie', cookie(res)) + .expect(200, 'bob', done); + }); + }); + }) + }) + + describe('unset option', function () { + it('should reject unknown values', function(){ + assert.throws(session.bind(null, { unset: 'bogus!' }), /unset.*must/) + }); + + it('should default to keep', function(done){ + var store = new session.MemoryStore(); + 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(server) + .get('/') + .expect(200, function(err, res){ + if (err) return done(err); + store.length(function(err, len){ + if (err) return done(err); + 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.strictEqual(len, 1) + done(); + }); + }); + }); + }); + }); + + it('should allow destroy on req.session = null', function(done){ + var store = new session.MemoryStore(); + 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(server) + .get('/') + .expect(200, function(err, res){ + if (err) return done(err); + store.length(function(err, len){ + if (err) return done(err); + 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.strictEqual(len, 0) + done(); + }); + }); + }); + }); + }); + + it('should not set cookie if initial session destroyed', function(done){ + var store = new session.MemoryStore(); + var server = createServer({ store: store, unset: 'destroy' }, function (req, res) { + req.session = null + res.end() + }) + + 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.strictEqual(len, 0) + done(); + }); + }); + }); + + it('should pass session destroy error', function (done) { + var cb = after(2, done) + var store = new session.MemoryStore() + var server = createServer({ store: store, unset: 'destroy' }, function (req, res) { + req.session = null + res.end('session destroyed') + }) + + store.destroy = function destroy(sid, callback) { + callback(new Error('boom!')) + } + + server.on('error', function onerror(err) { + assert.ok(err) + assert.strictEqual(err.message, 'boom!') + cb() + }) + + request(server) + .get('/') + .expect(200, 'session destroyed', cb) + }) + }); + + describe('res.end patch', function () { + it('should correctly handle res.end/res.write patched prior', function (done) { + function setup (req, res) { + utils.writePatch(res) + } + + function respond (req, res) { + req.session.hit = true + res.write('hello, ') + res.end('world') + } + + request(createServer(setup, null, respond)) + .get('/') + .expect(200, 'hello, world', done) + }) + + it('should correctly handle res.end/res.write patched after', function (done) { + function respond (req, res) { + utils.writePatch(res) + req.session.hit = true + res.write('hello, ') + res.end('world') + } + + 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(){ + it('should persist', function(done){ + var store = new session.MemoryStore() + var server = createServer({ store: store }, function (req, res) { + req.session.count = req.session.count || 0 + req.session.count++ + res.end('hits: ' + req.session.count) + }) + + request(server) + .get('/') + .expect(200, 'hits: 1', function (err, res) { + if (err) return done(err) + store.load(sid(res), function (err, sess) { + if (err) return done(err) + assert.ok(sess) + request(server) + .get('/') + .set('Cookie', cookie(res)) + .expect(200, 'hits: 2', done) + }) + }) + }) + + 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()) + }) + + request(server) + .get('/') + .expect(200, '1', function (err, res) { + if (err) return done(err) + request(server) + .get('/') + .set('Cookie', cookie(res)) + .expect(200, '2', function (err, res) { + if (err) return done(err) + var val = cookie(res); + modify = false; + + request(server) + .get('/') + .set('Cookie', val) + .expect(shouldNotHaveHeader('Set-Cookie')) + .expect(200, '2', function (err, res) { + if (err) return done(err) + modify = true; + + request(server) + .get('/') + .set('Cookie', val) + .expect(shouldSetCookie('connect.sid')) + .expect(200, '3', done) + }); + }); + }); + }) + + 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 server = createServer(null, function (req, res) { + req.session.destroy(function (err) { + if (err) res.statusCode = 500 + res.end(String(req.session)) + }) + }) + + request(server) + .get('/') + .expect(shouldNotHaveHeader('Set-Cookie')) + .expect(200, 'undefined', done) + }) + }) + + describe('.regenerate()', function(){ + it('should destroy/replace the previous session', function(done){ + 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(server) + .get('/') + .expect(shouldSetCookie('connect.sid')) + .expect(200, function (err, res) { + if (err) return done(err) + request(server) + .get('/') + .set('Cookie', cookie(res)) + .expect(shouldSetCookie('connect.sid')) + .expect(shouldSetCookieToDifferentSessionId(sid(res))) + .expect(200, 'false', done) + }); + }) + }) + + describe('.reload()', function () { + it('should reload session from store', function (done) { + var server = createServer(null, function (req, res) { + if (req.url === '/') { + req.session.active = true + res.end('session created') + return + } + + req.session.url = req.url + + if (req.url === '/bar') { + res.end('saw ' + req.session.url) + return + } + + request(server) + .get('/bar') + .set('Cookie', val) + .expect(200, 'saw /bar', function (err, resp) { + if (err) return done(err) + req.session.reload(function (err) { + if (err) return done(err) + res.end('saw ' + req.session.url) + }) + }) + }) + var val + + request(server) + .get('/') + .expect(200, 'session created', function (err, res) { + if (err) return done(err) + val = cookie(res) + request(server) + .get('/foo') + .set('Cookie', val) + .expect(200, 'saw /bar', done) + }) + }) + + it('should error is session missing', function (done) { + var store = new session.MemoryStore() + var server = createServer({ store: store }, function (req, res) { + if (req.url === '/') { + req.session.active = true + res.end('session created') + return + } + + store.clear(function (err) { + if (err) return done(err) + req.session.reload(function (err) { + res.statusCode = err ? 500 : 200 + res.end(err ? err.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(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 () { + it('should save session to store', function (done) { + var store = new session.MemoryStore() + var server = createServer({ store: store }, function (req, res) { + req.session.hit = true + req.session.save(function (err) { + if (err) return res.end(err.message) + store.get(req.session.id, function (err, sess) { + if (err) return res.end(err.message) + res.end(sess ? 'stored' : 'empty') + }) + }) + }) + + request(server) + .get('/') + .expect(200, 'stored', done) + }) + + it('should prevent end-of-request save', function (done) { + var store = new session.MemoryStore() + var server = createServer({ 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) + }) + }) + + 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) + request(server) + .get('/') + .set('Cookie', cookie(res)) + .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) + }) + }) + }) + }) + + describe('.touch()', function () { + it('should reset session expiration', function (done) { + var store = new session.MemoryStore() + var server = createServer({ resave: false, store: store, cookie: { maxAge: min } }, function (req, res) { + req.session.hit = true + req.session.touch() + res.end() + }) + + request(server) + .get('/') + .expect(200, function (err, res) { + if (err) return done(err) + var id = sid(res) + store.get(id, function (err, sess) { + if (err) return done(err) + var exp = new Date(sess.cookie.expires) + 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.notStrictEqual(new Date(sess.cookie.expires).getTime(), exp.getTime()) + done() + }) + }) + }, 100) + }) + }) + }) + }) + + describe('.cookie', function(){ + describe('.*', function(){ + it('should serialize as parameters', function(done){ + var server = createServer({ proxy: true }, function (req, res) { + req.session.cookie.httpOnly = false + req.session.cookie.secure = true + res.end() + }) + + request(server) + .get('/') + .set('X-Forwarded-Proto', 'https') + .expect(shouldSetCookieWithoutAttribute('connect.sid', 'HttpOnly')) + .expect(shouldSetCookieWithAttribute('connect.sid', 'Secure')) + .expect(200, done) + }) + + it('should default to a browser-session length cookie', function(done){ + request(createServer({ cookie: { path: '/admin' } })) + .get('/admin') + .expect(shouldSetCookieWithoutAttribute('connect.sid', 'Expires')) + .expect(200, done) + }) + + it('should Set-Cookie only once for browser-session cookies', function(done){ + var server = createServer({ cookie: { path: '/admin' } }) + + request(server) + .get('/admin/foo') + .expect(shouldSetCookie('connect.sid')) + .expect(200, function (err, res) { + if (err) return done(err) + request(server) + .get('/admin') + .set('Cookie', cookie(res)) + .expect(shouldNotHaveHeader('Set-Cookie')) + .expect(200, done) + }); + }) + + it('should override defaults', function(done){ + 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(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 server = createServer(null, function (req, res) { + var cookie = new Cookie() + res.setHeader('Set-Cookie', cookie.serialize('previous', 'cookieValue')) + res.end() + }) + + 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(){ + var app + + before(function () { + app = createRequestListener({ secret: 'keyboard cat', cookie: { secure: true } }) + }) + + it('should set cookie when secure', 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', app) + + 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(shouldSetCookie('connect.sid')) + req.expect(200, done) + }) + + it('should not set-cookie when insecure', function(done){ + var server = http.createServer(app) + + request(server) + .get('/') + .expect(shouldNotHaveHeader('Set-Cookie')) + .expect(200, done) + }) + }) + + 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(ctx.server) + .get('/') + .end(function (err, res) { + ctx.cookie = res && cookie(res) + done(err) + }) + }) + + 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) + }) + + 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) + }) + + it('should modify cookie expires when changed to large value', function (done) { + request(this.server) + .get('/') + .set('Cookie', this.cookie) + .expect(shouldSetCookieToExpireIn('connect.sid', 3000000000)) + .expect(200, '3', done) + }) + }) + + 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(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 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(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('.partitioned', function () { + describe('by default', function () { + it('should not set partitioned attribute', function (done) { + var server = createServer() + + request(server) + .get('/') + .expect(shouldSetCookieWithoutAttribute('connect.sid', 'Partitioned')) + .expect(200, done) + }) + }) + + describe('when "false"', function () { + it('should not set partitioned attribute', function (done) { + var server = createServer({ cookie: { partitioned: false } }) - var app = express() - .use(cookieParser()) - .use(session({ secret: 'keyboard cat', cookie: { maxAge: min }})) - .use(function(req, res){ - req.session.count = ++n; - res.end(); - }) + request(server) + .get('/') + .expect(shouldSetCookieWithoutAttribute('connect.sid', 'Partitioned')) + .expect(200, done) + }) + }) - request(app) - .get('/') - .end(function(err, res){ + describe('when "true"', function () { + it('should set partitioned attribute', function (done) { + var server = createServer({ cookie: { partitioned: true } }) - var id = sid(res); - request(app) - .get('/') - .set('Cookie', 'connect.sid=' + id) - .end(function(err, res){ - sid(res).should.equal(id); - done(); - }); - }); + request(server) + .get('/') + .expect(shouldSetCookieWithAttribute('connect.sid', 'Partitioned')) + .expect(200, done) + }) + }) + }) + }) }) - describe('when an invalid sid is given', function(){ - it('should generate a new one', function(done){ - request(app) + describe('synchronous store', function(){ + it('should respond correctly on save', function(done){ + var store = new SyncStore() + var server = createServer({ store: store }, function (req, res) { + req.session.count = req.session.count || 0 + req.session.count++ + res.end('hits: ' + req.session.count) + }) + + request(server) .get('/') - .set('Cookie', 'connect.sid=foobarbaz') - .end(function(err, res){ - sid(res).should.not.equal('foobarbaz'); - done(); - }); + .expect(200, 'hits: 1', done) }) - }) - it('should issue separate sids', function(done){ - var n = 0; + it('should respond correctly on destroy', function(done){ + var store = new SyncStore() + var server = createServer({ store: store, unset: 'destroy' }, function (req, res) { + req.session.count = req.session.count || 0 + var count = ++req.session.count + if (req.session.count > 1) { + req.session = null + res.write('destroyed\n') + } + res.end('hits: ' + count) + }) - var app = express() - .use(cookieParser()) - .use(session({ secret: 'keyboard cat', cookie: { maxAge: min }})) - .use(function(req, res){ - req.session.count = ++n; - res.end(); + request(server) + .get('/') + .expect(200, 'hits: 1', function (err, res) { + if (err) return done(err) + request(server) + .get('/') + .set('Cookie', cookie(res)) + .expect(200, 'destroyed\nhits: 2', done) }) + }) + }) - request(app) - .get('/') - .end(function(err, res){ + describe('cookieParser()', function () { + it('should read from req.cookies', function(done){ + var app = express() + .use(cookieParser()) + .use(function(req, res, next){ req.headers.cookie = 'foo=bar'; next() }) + .use(createSession()) + .use(function(req, res, next){ + req.session.count = req.session.count || 0 + req.session.count++ + res.end(req.session.count.toString()) + }) - var id = sid(res); request(app) .get('/') - .set('Cookie', 'connect.sid=' + id) - .end(function(err, res){ - sid(res).should.equal(id); - + .expect(200, '1', function (err, res) { + if (err) return done(err) request(app) .get('/') - .end(function(err, res){ - sid(res).should.not.equal(id); - done(); - }); - }); - }); - }) + .set('Cookie', cookie(res)) + .expect(200, '2', done) + }) + }) - describe('req.session', function(){ - it('should persist', function(done){ + it('should reject unsigned from req.cookies', function(done){ var app = express() .use(cookieParser()) - .use(session({ secret: 'keyboard cat', cookie: { maxAge: min, httpOnly: false }})) + .use(function(req, res, next){ req.headers.cookie = 'foo=bar'; next() }) + .use(createSession({ key: 'sessid' })) .use(function(req, res, next){ - // checks that cookie options persisted - req.session.cookie.httpOnly.should.equal(false); - - req.session.count = req.session.count || 0; - req.session.count++; - res.end(req.session.count.toString()); - }); + req.session.count = req.session.count || 0 + req.session.count++ + res.end(req.session.count.toString()) + }) request(app) .get('/') - .end(function(err, res){ - res.text.should.equal('1'); - + .expect(200, '1', function (err, res) { + if (err) return done(err) request(app) .get('/') - .set('Cookie', 'connect.sid=' + sid(res)) - .end(function(err, res){ - res.text.should.equal('2'); - done(); - }); - }); + .set('Cookie', 'sessid=' + sid(res)) + .expect(200, '1', done) + }) }) - it('should only set-cookie when modified', function(done){ - var modify = true; - + it('should reject invalid signature from req.cookies', function(done){ var app = express() .use(cookieParser()) - .use(session({ secret: 'keyboard cat', cookie: { maxAge: min }})) + .use(function(req, res, next){ req.headers.cookie = 'foo=bar'; next() }) + .use(createSession({ key: 'sessid' })) .use(function(req, res, next){ - if (modify) { - req.session.count = req.session.count || 0; - req.session.count++; - } - res.end(req.session.count.toString()); - }); + req.session.count = req.session.count || 0 + req.session.count++ + res.end(req.session.count.toString()) + }) request(app) .get('/') - .end(function(err, res){ - res.text.should.equal('1'); - + .expect(200, '1', function (err, res) { + if (err) return done(err) + var val = cookie(res).replace(/...\./, '.') request(app) .get('/') - .set('Cookie', 'connect.sid=' + sid(res)) - .end(function(err, res){ - var id = sid(res); - res.text.should.equal('2'); - modify = false; - - request(app) - .get('/') - .set('Cookie', 'connect.sid=' + sid(res)) - .end(function(err, res){ - sid(res).should.be.empty; - res.text.should.equal('2'); - modify = true; - - request(app) - .get('/') - .set('Cookie', 'connect.sid=' + id) - .end(function(err, res){ - sid(res).should.not.be.empty; - res.text.should.equal('3'); - done(); - }); - }); - }); - }); + .set('Cookie', val) + .expect(200, '1', done) + }) }) - describe('.destroy()', function(){ - it('should destroy the previous session', function(done){ - var app = express() - .use(cookieParser()) - .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(); - }); - }); + it('should read from req.signedCookies', function(done){ + var app = express() + .use(cookieParser('keyboard cat')) + .use(function(req, res, next){ delete req.headers.cookie; next() }) + .use(createSession()) + .use(function(req, res, next){ + req.session.count = req.session.count || 0 + req.session.count++ + res.end(req.session.count.toString()) + }) + request(app) + .get('/') + .expect(200, '1', function (err, res) { + if (err) return done(err) request(app) .get('/') - .end(function(err, res){ - res.headers.should.not.have.property('set-cookie'); - done(); - }); + .set('Cookie', cookie(res)) + .expect(200, '2', done) }) }) + }) +}) - describe('.regenerate()', function(){ - it('should destroy/replace the previous session', function(done){ - var app = express() - .use(cookieParser()) - .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; - id.should.not.equal(req.session.id); - res.end(); - }); - }); +describe('path matching (RFC 6265)', function () { + describe('when "path" is "/" (root path)', function () { + before(function () { + this.server = createServer({ cookie: { path: '/' } }) + }) - request(app) + 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('/') - .end(function(err, res){ - var id = sid(res); + .expect(shouldSetCookie('connect.sid')) + .expect(200, done) + }) - request(app) - .get('/') - .set('Cookie', 'connect.sid=' + id) - .end(function(err, res){ - sid(res).should.not.equal(''); - sid(res).should.not.equal(id); - 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) }) - describe('.cookie', function(){ - describe('.*', function(){ - it('should serialize as parameters', function(done){ - var app = express() - .use(cookieParser()) - .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(); - }); + 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) + }) + }) - request(app) - .get('/') - .set('X-Forwarded-Proto', 'https') - .end(function(err, res){ - res.headers['set-cookie'][0].should.not.include('HttpOnly'); - res.headers['set-cookie'][0].should.include('Secure'); - done(); - }); - }) + describe('when "path" is "/admin"', function () { + before(function () { + this.server = createServer({ cookie: { path: '/admin' } }) + }) - it('should default to a browser-session length cookie', function(done){ - var app = express() - .use(cookieParser()) - .use(session({ secret: 'keyboard cat', cookie: { path: '/admin' }})) - .use(function(req, res, next){ - res.end(); - }); + 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) + }) - request(app) - .get('/admin') - .end(function(err, res){ - var cookie = res.headers['set-cookie'][0]; - cookie.should.not.include('Expires'); - 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 only once for browser-session cookies', function(done){ - var app = express() - .use(cookieParser()) - .use(session({ secret: 'keyboard cat', cookie: { path: '/admin' }})) - .use(function(req, res, next){ - res.end(); - }); + 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) + }) - request(app) - .get('/admin/foo') - .end(function(err, res){ - res.headers.should.have.property('set-cookie'); + 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) + }) + }) - request(app) - .get('/admin') - .set('Cookie', 'connect.sid=' + sid(res)) - .end(function(err, res){ - res.headers.should.not.have.property('set-cookie'); - done(); - }) - }); - }) + describe('when "path" is "/admin/" (trailing slash)', function () { + before(function () { + this.server = createServer({ cookie: { path: '/admin/' } }) + }) - it('should override defaults', function(done){ - var app = express() - .use(cookieParser()) - .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(); - }); + 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) + }) - request(app) - .get('/admin') - .end(function(err, res){ - var cookie = res.headers['set-cookie'][0]; - cookie.should.not.include('HttpOnly'); - cookie.should.not.include('Secure'); - cookie.should.include('Path=/admin'); - cookie.should.include('Expires'); - 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 preserve cookies set before writeHead is called', function(done){ - function getPreviousCookie(res) { - var val = res.headers['set-cookie']; - if (!val) return ''; - return /previous=([^;]+);/.exec(val[0])[1]; - } + 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) + }) + }) +}) - var app = express() - .use(cookieParser('keyboard cat')) - .use(session()) - .use(function(req, res, next){ - var cookie = new Cookie(); - res.setHeader('Set-Cookie', cookie.serialize('previous', 'cookieValue')); - res.end(); - }); - request(app) - .get('/') - .end(function(err, res){ - getPreviousCookie(res).should.equal('cookieValue'); - done(); - }); - }) - }) +function cookie(res) { + var setCookie = res.headers['set-cookie']; + return (setCookie && setCookie[0]) || undefined; +} - describe('.secure', function(){ - it('should not set-cookie when insecure', function(done){ - var app = express() - .use(cookieParser()) - .use(session({ secret: 'keyboard cat' })) - .use(function(req, res, next){ - req.session.cookie.secure = true; - res.end(); - }); +function createServer (options, respond) { + var fn = respond + var opts = options + var server = http.createServer() - request(app) - .get('/') - .end(function(err, res){ - res.headers.should.not.have.property('set-cookie'); - done(); - }); - }) - }) + // setup, options, respond + if (typeof arguments[0] === 'function') { + opts = arguments[1] + fn = arguments[2] - describe('when the pathname does not match cookie.path', function(){ - it('should not set-cookie', function(done){ - var app = express() - .use(cookieParser()) - .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(); - }); + server.on('request', arguments[0]) + } - request(app) - .get('/') - .end(function(err, res){ - res.status.should.equal(200); - res.headers.should.not.have.property('set-cookie'); - done(); - }); - }) + return server.on('request', createRequestListener(opts, fn)) +} - it('should not set-cookie even for FQDN', function(done){ - var app = express() - .use(cookieParser()) - .use(session({ secret: 'keyboard cat', cookie: { path: '/foo/bar' }})) - .use(function(req, res, next){ - if (!req.session) { - return res.end(); - } +function createRequestListener(opts, fn) { + var _session = createSession(opts) + var respond = fn || end - req.session.foo = Math.random(); - res.end(); - }); + return function onRequest(req, res) { + var server = this - request(app) - .get('/') - .set('host', 'http://foo/bar') - .end(function(err, res){ - res.status.should.equal(200); - res.headers.should.not.have.property('set-cookie'); - done(); - }); - }) - }) + _session(req, res, function (err) { + if (err && !res._header) { + res.statusCode = err.status || 500 + res.end(err.message) + return + } - describe('when the pathname does match cookie.path', function(){ - it('should set-cookie', function(done){ - var app = express() - .use(cookieParser()) - .use(session({ secret: 'keyboard cat', cookie: { path: '/foo/bar' }})) - .use(function(req, res, next){ - req.session.foo = Math.random(); - res.end(); - }); + if (err) { + server.emit('error', err) + return + } - request(app) - .get('/foo/bar/baz') - .end(function(err, res){ - res.headers.should.have.property('set-cookie'); - done(); - }); - }) + respond(req, res) + }) + } +} - it('should set-cookie even for FQDN', function(done){ - var app = express() - .use(cookieParser()) - .use(session({ secret: 'keyboard cat', cookie: { path: '/foo/bar' }})) - .use(function(req, res, next){ - req.session.foo = Math.random(); - res.end(); - }); +function createSession(opts) { + var options = opts || {} - request(app) - .get('/foo/bar/baz') - .set('host', 'http://example.com') - .end(function(err, res){ - res.status.should.equal(200); - res.headers.should.have.property('set-cookie'); - done(); - }); - }) - }) + if (!('cookie' in options)) { + options.cookie = { maxAge: 60 * 1000 } + } - describe('.maxAge', function(){ - var id; - var app = express() - .use(cookieParser()) - .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()); - }); + if (!('secret' in options)) { + options.secret = 'keyboard cat' + } - it('should set relative in milliseconds', function(done){ - request(app) - .get('/') - .end(function(err, res){ - var a = new Date(expires(res)) - , b = new Date; + return session(options) +} + +function end(req, res) { + res.end() +} - id = sid(res); +function expires (res) { + var header = cookie(res) + return header && utils.parseSetCookie(header).expires +} - a.getYear().should.equal(b.getYear()); - a.getMonth().should.equal(b.getMonth()); - a.getDate().should.equal(b.getDate()); - a.getSeconds().should.not.equal(b.getSeconds()); - var delta = a.valueOf() - b.valueOf(); - (delta > 1000 && delta < 2000).should.be.ok; - res.text.should.equal('1'); - done(); - }); - }); +function mountAt (path) { + return function (req, res) { + if (req.url.indexOf(path) === 0) { + req.originalUrl = req.url + req.url = req.url.slice(path.length) + } + } +} - it('should modify cookie when changed', function(done){ - request(app) - .get('/') - .set('Cookie', 'connect.sid=' + id) - .end(function(err, res){ - var a = new Date(expires(res)) - , b = new Date; +function shouldNotHaveHeader(header) { + return function (res) { + assert.ok(!(header.toLowerCase() in res.headers), 'should not have ' + header + ' header') + } +} - id = sid(res); +function shouldNotSetSessionInStore(store) { + var _set = store.set + var count = 0 - a.getYear().should.equal(b.getYear()); - a.getMonth().should.equal(b.getMonth()); - a.getSeconds().should.not.equal(b.getSeconds()); - var delta = a.valueOf() - b.valueOf(); - (delta > 4000 && delta < 5000).should.be.ok; - res.text.should.equal('2'); - done(); - }); - }); + store.set = function set () { + count++ + return _set.apply(this, arguments) + } - it('should modify cookie when changed to large value', function(done){ - request(app) - .get('/') - .set('Cookie', 'connect.sid=' + id) - .end(function(err, res){ - var a = new Date(expires(res)) - , b = new Date; + return function () { + assert.ok(count === 0, 'should not set session in store') + } +} - id = sid(res); +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) + } +} - var delta = a.valueOf() - b.valueOf(); - (delta > 2999999000 && delta < 3000000000).should.be.ok; - res.text.should.equal('3'); - done(); - }); - }); - }) +function shouldSetCookieToDifferentSessionId (id) { + return function (res) { + assert.notStrictEqual(sid(res), id) + } +} - describe('.expires', function(){ - describe('when given a Date', function(){ - it('should set absolute', function(done){ - var app = express() - .use(cookieParser()) - .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){ - expires(res).should.equal('Thu, 01 Jan 1970 00:00:00 GMT'); - done(); - }); - }) - }) +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') + } +} - describe('when null', function(){ - it('should be a browser-session cookie', function(done){ - var app = express() - .use(cookieParser()) - .use(session({ secret: 'keyboard cat' })) - .use(function(req, res, next){ - req.session.cookie.expires = null; - res.end(); - }); - - request(app) - .get('/') - .end(function(err, res){ - res.headers['set-cookie'][0].should.not.include('Expires='); - done(); - }); - }) - }) - }) - }) +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) + } +} - it('should support req.signedCookies', function(done){ - var app = express() - .use(cookieParser('keyboard cat')) - .use(session()) - .use(function(req, res, next){ - req.session.count = req.session.count || 0; - req.session.count++; - res.end(req.session.count.toString()); - }); +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.strictEqual(data.name, name, 'should set cookie ' + name) + assert.ok((attrib.toLowerCase() in data), 'should set cookie with attribute ' + attrib) + } +} - request(app) - .get('/') - .end(function(err, res){ - res.text.should.equal('1'); +function shouldSetCookieWithAttributeAndValue (name, attrib, value) { + 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 with attribute ' + attrib) + assert.strictEqual(data[attrib.toLowerCase()], value, 'should set cookie with attribute ' + attrib + ' set to ' + value) + } +} - request(app) - .get('/') - .set('Cookie', 'connect.sid=' + sid(res)) - .end(function(err, res){ - res.text.should.equal('2'); - done(); - }); - }); - }) +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 shouldSetSessionInStore (store, delay) { + var _set = store.set + var count = 0 + + store.set = function set () { + count++ + + if (!delay) { + return _set.apply(this, arguments) + } + + var args = new Array(arguments.length + 1) + + args[0] = this + for (var i = 1; i < args.length; i++) { + args[i] = arguments[i - 1] + } + + setTimeout(_set.bind.apply(_set, args), delay) + } + + return function () { + assert.ok(count === 1, 'should set session in store') + } +} + +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) + } +}